Changelog

Every release, aggregated straight from each package’s own changelog — nothing curated, nothing held back. Grouped by pack, newest version first.

Last generated November 10, 2026.

Core

v1.35.0
v1.35.0minor

f321f0b: `applyFormPrefill` now also reads the query string of a clicked link. A link that points at the page's form and carries `?field=value` pairs in its `href` preselects those fields, with the same allowlist as the page URL, so a plain `href` works with no extra attribute. A link that also has `data-form-prefill` keeps its earlier behavior and wins if the two disagree. The click listener is now registered in the capture phase: a client-routing link such as `next/link` cancels the click's default action in its own handler, which used to hide the click from the helper. Nothing changes for a form with no allowlist or for `links: false`.

  • f321f0b: `applyFormPrefill` now also reads the query string of a clicked link. A link that points at the page's form and carries `?field=value` pairs in its `href` preselects those fields, with the same allowlist as the page URL, so a plain `href` works with no extra attribute. A link that also has `data-form-prefill` keeps its earlier behavior and wins if the two disagree. The click listener is now registered in the capture phase: a client-routing link such as `next/link` cancels the click's default action in its own handler, which used to hide the click from the helper. Nothing changes for a form with no allowlist or for `links: false`.
v1.34.0minor

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v1.33.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v1.33.0minor

43dccea: Add `@wabbit/tome-core/auth/session-keep-alive` (`SessionKeepAlive`, `rollSession`) and a `session.deferSessionRefresh` option on `createBetterAuth`. Together they keep the session cookie moving for active members: a page render can't set a cookie, so without them the cookie expires a fixed `expiresIn` after sign-in. The option is off by default and must be paired with `<SessionKeepAlive />`, because with it on nothing else refreshes the session. It needs a Better Auth version that has `deferSessionRefresh` (1.6.20 or later).

  • 43dccea: Add `@wabbit/tome-core/auth/session-keep-alive` (`SessionKeepAlive`, `rollSession`) and a `session.deferSessionRefresh` option on `createBetterAuth`. Together they keep the session cookie moving for active members: a page render can't set a cookie, so without them the cookie expires a fixed `expiresIn` after sign-in. The option is off by default and must be paired with `<SessionKeepAlive />`, because with it on nothing else refreshes the session. It needs a Better Auth version that has `deferSessionRefresh` (1.6.20 or later).
v1.32.0minor

666f4eb: Add `@wabbit/tome-core/utilities/safeHref`: the one link-safety rule, lifted from `@wabbit/tome-blocks-local-pack` so a layer's save hook and a render pack apply the same check. `safeHref(value)` returns the trimmed link for http(s), mailto, tel, a path or an anchor, and `null` for any other scheme, a protocol-relative `//host`, or whitespace and control characters. Also adds `@wabbit/tome-core/utilities/requestMemo` (a per-request `Map` keyed on the stable `req` object) and `@wabbit/tome-core/data/readThroughCache` (a read through Next's `unstable_cache` when it is there, directly when it is not), so layers share one implementation instead of copying one.

  • 666f4eb: Add `@wabbit/tome-core/utilities/safeHref`: the one link-safety rule, lifted from `@wabbit/tome-blocks-local-pack` so a layer's save hook and a render pack apply the same check. `safeHref(value)` returns the trimmed link for http(s), mailto, tel, a path or an anchor, and `null` for any other scheme, a protocol-relative `//host`, or whitespace and control characters. Also adds `@wabbit/tome-core/utilities/requestMemo` (a per-request `Map` keyed on the stable `req` object) and `@wabbit/tome-core/data/readThroughCache` (a read through Next's `unstable_cache` when it is there, directly when it is not), so layers share one implementation instead of copying one.
v1.31.0minor

7f041d8: `createBetterAuth` gains `enrichSessionRoles` (default `true`), so a site can turn off the role lookup the factory adds to every session fetch. The factory's `customSession` plugin loads the user at depth 1 on every `getSession` and adds `user.role`, `orgRole` and `activeOrganizationId`. Better Auth's admin login view needs `user.role`, so the default stays on. A site that keeps Payload's own `/admin` login and doesn't read `session.user.role` can pass `enrichSessionRoles: false` and skip one `users` read per session fetch; if it uses the Better Auth login view, it must also pass `admin.requiredRole: null`.

  • 7f041d8: `createBetterAuth` gains `enrichSessionRoles` (default `true`), so a site can turn off the role lookup the factory adds to every session fetch. The factory's `customSession` plugin loads the user at depth 1 on every `getSession` and adds `user.role`, `orgRole` and `activeOrganizationId`. Better Auth's admin login view needs `user.role`, so the default stays on. A site that keeps Payload's own `/admin` login and doesn't read `session.user.role` can pass `enrichSessionRoles: false` and skip one `users` read per session fetch; if it uses the Better Auth login view, it must also pass `admin.requiredRole: null`.
v1.30.1patch

a5fd548: `createBetterAuth` no longer enables the adapter's first-user-admin hook, which never worked on Tome's `roles` relationship and logged an error on every first sign-up. The hook writes a `role` string on the first sign-up. Tome users hold a `roles` relationship instead, so the write was dropped, its follow-up query failed ("first-user-admin race resolution failed ... path cannot be queried: role"), and nobody was made admin by it. Sites assign the first admin in their own users hook. Pass `firstUserAdmin: true` only for a users collection that stores roles in a plain `role` field. With the hook off, the adapter logs a one-time reminder at boot that `access.create` on users must reject non-admin callers; the starter template already does, and a sign-up that sends `roles` is stored with the default role.

  • a5fd548: `createBetterAuth` no longer enables the adapter's first-user-admin hook, which never worked on Tome's `roles` relationship and logged an error on every first sign-up. The hook writes a `role` string on the first sign-up. Tome users hold a `roles` relationship instead, so the write was dropped, its follow-up query failed ("first-user-admin race resolution failed ... path cannot be queried: role"), and nobody was made admin by it. Sites assign the first admin in their own users hook. Pass `firstUserAdmin: true` only for a users collection that stores roles in a plain `role` field. With the hook off, the adapter logs a one-time reminder at boot that `access.create` on users must reject non-admin callers; the starter template already does, and a sign-up that sends `roles` is stored with the default role.
v1.30.0minor

70bf5fe: Add `staffOrPublished(staff)` (exported from `@wabbit/tome-core/auth` and `@wabbit/tome-core/access`): staff see drafts, everyone else, signed in or not, sees only published documents. It's the safe replacement for `authenticatedOrPublished` on sites with public signup, where that guard shows drafts to every account. Only an explicit `true` from `staff` opens drafts; a `Where` is OR-ed with the published filter. The `authenticatedOrPublished` warnings now point at it.

  • 70bf5fe: Add `staffOrPublished(staff)` (exported from `@wabbit/tome-core/auth` and `@wabbit/tome-core/access`): staff see drafts, everyone else, signed in or not, sees only published documents. It's the safe replacement for `authenticatedOrPublished` on sites with public signup, where that guard shows drafts to every account. Only an explicit `true` from `staff` opens drafts; a `Where` is OR-ed with the published filter. The `authenticatedOrPublished` warnings now point at it.
v1.29.0minor

988a814: `secureAccessDefaults` now also covers the collections Payload adds after plugins run: edit locks, migrations, folders and the jobs-stats global. Payload creates `payload-locked-documents`, `payload-migrations`, the folders collection and (when a task has a schedule) the `payload-jobs-stats` global while it finalizes the config, after every plugin has run, and gives them "any signed-in account" on every operation. On a site with public signup, any account could list who is editing what and create or delete edit locks on admin documents. The plugin now installs `staff` on them: folders through a `folders.collectionOverrides` entry that runs before yours (so your overrides still win), the others at init on the live config, before your own `onInit`. Lock reads go to everyone allowed into the admin panel (the admin-user collection's `access.admin`), because Payload's admin reads locks as the signed-in user; lock writes go to `staff`. `exclude` takes these slugs, report mode lists them, and `installLateSystemAccess` is exported for configs built without the plugin.

  • 988a814: `secureAccessDefaults` now also covers the collections Payload adds after plugins run: edit locks, migrations, folders and the jobs-stats global. Payload creates `payload-locked-documents`, `payload-migrations`, the folders collection and (when a task has a schedule) the `payload-jobs-stats` global while it finalizes the config, after every plugin has run, and gives them "any signed-in account" on every operation. On a site with public signup, any account could list who is editing what and create or delete edit locks on admin documents. The plugin now installs `staff` on them: folders through a `folders.collectionOverrides` entry that runs before yours (so your overrides still win), the others at init on the live config, before your own `onInit`. Lock reads go to everyone allowed into the admin panel (the admin-user collection's `access.admin`), because Payload's admin reads locks as the signed-in user; lock writes go to `staff`. `exclude` takes these slugs, report mode lists them, and `installLateSystemAccess` is exported for configs built without the plugin.
v1.28.0minor

78d9bf7: Layer factories accept `staff`, which gives every operation a layer left without an access rule to the site's staff check. `LayerFactoryConfig.staff` (applied by `applyLayerFactoryConfig`, so every `createXLayer` that passes its config through gets it): each missing `create`/`read`/`update`/`delete`/`readVersions`, plus `admin`/`unlock` on an auth collection, becomes `staff`. Rules the layer wrote are untouched and per-collection `access` overrides still win. This closes gaps like tome-catalog's product variants (no write rules) and tome-org's members (no read rule) for a site that composes layers by hand, without the `secureAccessDefaults` plugin. Omitting `staff` changes nothing. Also exports `fillMissingAccess(collection, staff)` from `@wabbit/tome-core/access/secureAccessDefaults`, the shared rule behind both the plugin and the layer option.

  • 78d9bf7: Layer factories accept `staff`, which gives every operation a layer left without an access rule to the site's staff check. `LayerFactoryConfig.staff` (applied by `applyLayerFactoryConfig`, so every `createXLayer` that passes its config through gets it): each missing `create`/`read`/`update`/`delete`/`readVersions`, plus `admin`/`unlock` on an auth collection, becomes `staff`. Rules the layer wrote are untouched and per-collection `access` overrides still win. This closes gaps like tome-catalog's product variants (no write rules) and tome-org's members (no read rule) for a site that composes layers by hand, without the `secureAccessDefaults` plugin. Omitting `staff` changes nothing. Also exports `fillMissingAccess(collection, staff)` from `@wabbit/tome-core/access/secureAccessDefaults`, the shared rule behind both the plugin and the layer option.
v1.27.0minor

417c0b3: secureAccessDefaults now restricts the job queue to staff, and a new `jobsRunAccess` helper admits staff or the cron bearer token. Payload lets any signed-in account call `jobs.access.run`, `queue` and `cancel` when they are unset, and `run` guards the REST endpoints `GET /api/payload-jobs/run` and `/handle-schedules`, so any member could drain or trigger queued work. When the config has a `jobs` block, each missing rule now becomes `staff` (only an explicit `true` admits), reported as `jobs: run, queue, cancel` in the init summary. `exclude: ['payload-jobs']` opts out. The Local API (`payload.jobs.run()` from a cron route, a sidecar or `autoRun`) is unaffected. `jobsRunAccess({ staff, secretEnv? })` is `jobs.access.run` for a site whose external scheduler calls the job endpoints. It compares the `Authorization: Bearer <CRON_SECRET>` header in constant time and fails closed when the secret is unset (the Payload website template's version admits `Bearer undefined` in that case). Behavior change for sites already using `secureAccessDefaults`: after upgrading, non-staff accounts lose REST access to the job endpoints unless `jobs.access` sets a rule. No API, peer or required argument changed, so this ships as a minor.

  • 417c0b3: secureAccessDefaults now restricts the job queue to staff, and a new `jobsRunAccess` helper admits staff or the cron bearer token. Payload lets any signed-in account call `jobs.access.run`, `queue` and `cancel` when they are unset, and `run` guards the REST endpoints `GET /api/payload-jobs/run` and `/handle-schedules`, so any member could drain or trigger queued work. When the config has a `jobs` block, each missing rule now becomes `staff` (only an explicit `true` admits), reported as `jobs: run, queue, cancel` in the init summary. `exclude: ['payload-jobs']` opts out. The Local API (`payload.jobs.run()` from a cron route, a sidecar or `autoRun`) is unaffected. `jobsRunAccess({ staff, secretEnv? })` is `jobs.access.run` for a site whose external scheduler calls the job endpoints. It compares the `Authorization: Bearer <CRON_SECRET>` header in constant time and fails closed when the secret is unset (the Payload website template's version admits `Bearer undefined` in that case). Behavior change for sites already using `secureAccessDefaults`: after upgrading, non-staff accounts lose REST access to the job endpoints unless `jobs.access` sets a rule. No API, peer or required argument changed, so this ships as a minor.
v1.26.0minor

304f8fa: New `secureAccessDefaults` plugin (`@wabbit/tome-core/access/secureAccessDefaults`) gives every operation without an access rule to your `staff` check instead of "any signed-in user". Payload fills any access key you leave out with "any signed-in account". A missing `readVersions` does the same, `unlock` defaults to any account in the admin-user collection, and an auth collection without `access.admin` lets any signed-in user open the admin panel. On a site with public signup, or with members in the admin-user collection, that hands writes, drafts, version history and plugin-added records (form-builder submissions have no `delete` rule) to anyone who signs up. Add `secureAccessDefaults({ staff: isAdmin })` as the last plugin. It fills only the operations you didn't write, including those of plugin-added collections. It logs them once at init, and it warns when `form-submissions` still uses the form-builder plugin's default `read`. Run `mode: 'report'` first on an existing site to see the list without changing anything. `onReport` hands that list to a script or CI check when the config is built, before any database connection. Also new: - `formSubmissionAccess(staff)`: anyone may submit, staff may read and delete, and no one edits a submission over the API. - `isAdminOrPublished` in `@wabbit/tome-core/auth/guards`: the safe replacement for `authenticatedOrPublished` on content. - `basePlugins({ formBuilder: { staff } })`: applies both presets to the form-builder plugin. Your own `access` keys still win. The `authenticated` and `authenticatedOrPublished` docs now say plainly that they admit any signed-in account.

  • 304f8fa: New `secureAccessDefaults` plugin (`@wabbit/tome-core/access/secureAccessDefaults`) gives every operation without an access rule to your `staff` check instead of "any signed-in user". Payload fills any access key you leave out with "any signed-in account". A missing `readVersions` does the same, `unlock` defaults to any account in the admin-user collection, and an auth collection without `access.admin` lets any signed-in user open the admin panel. On a site with public signup, or with members in the admin-user collection, that hands writes, drafts, version history and plugin-added records (form-builder submissions have no `delete` rule) to anyone who signs up. Add `secureAccessDefaults({ staff: isAdmin })` as the last plugin. It fills only the operations you didn't write, including those of plugin-added collections. It logs them once at init, and it warns when `form-submissions` still uses the form-builder plugin's default `read`. Run `mode: 'report'` first on an existing site to see the list without changing anything. `onReport` hands that list to a script or CI check when the config is built, before any database connection. Also new: - `formSubmissionAccess(staff)`: anyone may submit, staff may read and delete, and no one edits a submission over the API. - `isAdminOrPublished` in `@wabbit/tome-core/auth/guards`: the safe replacement for `authenticatedOrPublished` on content. - `basePlugins({ formBuilder: { staff } })`: applies both presets to the form-builder plugin. Your own `access` keys still win. The `authenticated` and `authenticatedOrPublished` docs now say plainly that they admit any signed-in account.
v1.25.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v1.25.1patch

26850dd: Overview views keep Payload's horizontal gutter: the order and contact Overview tabs no longer sit flush against the admin nav and the window edge.

  • 26850dd: Overview views keep Payload's horizontal gutter: the order and contact Overview tabs no longer sit flush against the admin nav and the window edge.
v1.25.0minor

56f143c: New `@wabbit/tome-core/admin-views` subpath with presentational building blocks for Overview admin views, plus two optional product-type hooks. - `ProductTypeHooks` gains optional `getAccessSummary` (returns a `TomeAccessSummary` for admin access tiles) and `revokeAccess` (ends non-entitlement access after a refund; must be idempotent and never throw). - `@wabbit/tome-core/admin-views` ships server-safe primitives styled only with Payload admin CSS variables: `OverviewShell`, `Timeline` / `TimelineItem` / `TimelineGroup`, `SummaryRail` / `RailCard` / `RailRow` / `MoneyRow`, `StatusPill`, `StageBar`, `ProbabilityBar` and `FilterChips`. - `@payloadcms/ui` is now declared as an optional peer dependency; nothing to install unless you use the subpath.

  • 56f143c: New `@wabbit/tome-core/admin-views` subpath with presentational building blocks for Overview admin views, plus two optional product-type hooks. - `ProductTypeHooks` gains optional `getAccessSummary` (returns a `TomeAccessSummary` for admin access tiles) and `revokeAccess` (ends non-entitlement access after a refund; must be idempotent and never throw). - `@wabbit/tome-core/admin-views` ships server-safe primitives styled only with Payload admin CSS variables: `OverviewShell`, `Timeline` / `TimelineItem` / `TimelineGroup`, `SummaryRail` / `RailCard` / `RailRow` / `MoneyRow`, `StatusPill`, `StageBar`, `ProbabilityBar` and `FilterChips`. - `@payloadcms/ui` is now declared as an optional peer dependency; nothing to install unless you use the subpath.
v1.24.0minor

98ae5c8: Adds `signToken` and `verifyToken` to `@wabbit/tome-core/verification`: a generic expiring HMAC-SHA-256 signed token for signed links. - `signToken(payload, { secret, ttlMs | expiresAt, purpose? })` returns `data.signature` (base64url). `verifyToken(token, { secret, purpose? })` compares the signature in constant time, then checks the envelope, the purpose and the expiry, and returns `{ ok: true, payload, iat, exp }` or `{ ok: false, reason }` (`malformed`, `bad-signature`, `wrong-purpose`, `expired`). It never throws. - `purpose` is signed into the token, so a link minted for one use never verifies for another under the same secret. - The payload is signed, not encrypted: carry ids and a revocation version, never personal data. - `createProfileVerificationFlow`'s `issueToken`/`verifyToken` now delegate to it. Tokens it issued before still verify; a token is now expired at its `exp` instant rather than one millisecond after.

  • 98ae5c8: Adds `signToken` and `verifyToken` to `@wabbit/tome-core/verification`: a generic expiring HMAC-SHA-256 signed token for signed links. - `signToken(payload, { secret, ttlMs | expiresAt, purpose? })` returns `data.signature` (base64url). `verifyToken(token, { secret, purpose? })` compares the signature in constant time, then checks the envelope, the purpose and the expiry, and returns `{ ok: true, payload, iat, exp }` or `{ ok: false, reason }` (`malformed`, `bad-signature`, `wrong-purpose`, `expired`). It never throws. - `purpose` is signed into the token, so a link minted for one use never verifies for another under the same secret. - The payload is signed, not encrypted: carry ids and a revocation version, never personal data. - `createProfileVerificationFlow`'s `issueToken`/`verifyToken` now delegate to it. Tokens it issued before still verify; a token is now expired at its `exp` instant rather than one millisecond after.
v1.23.0minor

4a1728c: `@wabbit/tome-core/data/cacheTags` now exports `loadUnstableCache()` and the `UnstableCache` type: a lazy loader for Next's `unstable_cache` that returns `null` outside Next. Layers that cache public reads by tag share this one helper instead of each carrying a copy.

  • 4a1728c: `@wabbit/tome-core/data/cacheTags` now exports `loadUnstableCache()` and the `UnstableCache` type: a lazy loader for Next's `unstable_cache` that returns `null` outside Next. Layers that cache public reads by tag share this one helper instead of each carrying a copy.
  • d69161c: `ThemePalette` gains an optional `defaultMode`, and `@wabbit/tome-core/utilities/readingTime` adds one shared reading-time rule. - `ThemePalette.defaultMode?: 'light' | 'dark'` names the mode a palette opens in. `themeConfigHtmlAttributes(doc, { themes })` adds `data-theme="dark"` for a palette that opens dark, the ThemeConfig palette field names those palettes in its help text, and `registerTheme()` rejects a `defaultMode` the palette does not list in `modes`. Palettes without it behave exactly as before. - `readingTime` exports `READING_WPM` (230), `wordsInLexical(value)` (counts real text nodes only, including rich text nested in block fields such as a margin note's body) and `readingMinutes(words, wpm?)`. - `@wabbit/tome-core/utilities/formPrefill` is the framework-free link-prefill helper, moved here from the forms engine so a site that does not use `@wabbit/tome-forms` can use it: `applyFormPrefill(root, { allow, note, noteElement, search, skip, links })`, `parseFormPrefill`, `FORM_PREFILL_DEFAULT_NOTE` and `FORM_PREFILL_MAX_LENGTH`. It preselects allowlisted fields of any rendered form from `?field=value` and from clicks on in-page `data-form-prefill="field=value"` links. After a link click, focus lands on the checked radio itself (focusable even when only visually hidden); if the control cannot take focus (`display: none`), on its label, then on the first other focusable control in its group. `focusPrefilledControl(control, { preventScroll })` is exported for this. A fragment link's own hash navigation runs after the click handler and drops focus to `body` a few milliseconds later, so the handler focuses at once and then re-asserts focus after the navigation has settled (a timeout, then an animation frame), only when focus was lost; the link keeps its native hash behavior and `preventScroll` still holds.
v1.22.1patch

beebe4d: `basePlugins` types its `seo.generateTitle` and `seo.generateURL` from the SEO plugin's own config, so async generators and the plugin's full argument object type-check without a cast.

  • beebe4d: `basePlugins` types its `seo.generateTitle` and `seo.generateURL` from the SEO plugin's own config, so async generators and the plugin's full argument object type-check without a cast.
v1.22.0minor

2e9f1ab: New `@wabbit/tome-core/utilities/telHref` subpath: one `tel:` href builder with E.164 normalization, replacing four per-package copies that kept only the typed digits. `toTelHref(raw, { defaultCallingCode? })` returns `tel:+<digits>` and `toE164(raw, opts)` returns `+<digits>`, both `null` for fewer than three digits. A leading `+` is kept. Without one, a `defaultCallingCode` (`'1'`, `'44'`) qualifies the number: NANP numbers of 11 digits starting with `1` just gain the `+`, any other national number loses one leading trunk `0` and gains `+<code>`. With neither, `toTelHref` keeps the digits as typed, so nothing changes until a site opts in. Trailing `x123` / `ext. 123` becomes `;ext=123`; trailing `,` / `;` dial pauses pass through. `telOptionsFromContext(context)` reads `defaultCallingCode` from the host render context a block receives as `BlockRenderProps.context`.

  • 2e9f1ab: New `@wabbit/tome-core/utilities/telHref` subpath: one `tel:` href builder with E.164 normalization, replacing four per-package copies that kept only the typed digits. `toTelHref(raw, { defaultCallingCode? })` returns `tel:+<digits>` and `toE164(raw, opts)` returns `+<digits>`, both `null` for fewer than three digits. A leading `+` is kept. Without one, a `defaultCallingCode` (`'1'`, `'44'`) qualifies the number: NANP numbers of 11 digits starting with `1` just gain the `+`, any other national number loses one leading trunk `0` and gains `+<code>`. With neither, `toTelHref` keeps the digits as typed, so nothing changes until a site opts in. Trailing `x123` / `ext. 123` becomes `;ext=123`; trailing `,` / `;` dial pauses pass through. `telOptionsFromContext(context)` reads `defaultCallingCode` from the host render context a block receives as `BlockRenderProps.context`.
v1.21.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v1.21.0minor

020b41f: `CookieConsent` gains an optional `layout?: 'panel' | 'bar'` prop (default `'panel'`, unchanged from today), plus equal-weight Accept/Reject buttons. `'bar'` renders a full-width slim strip pinned to the bottom instead of the centered card — description and both buttons share one row when there's room, wrapping onto a compact second row on phone widths via flex-wrap rather than a fixed breakpoint. Fix: Accept and Reject now have equal visual weight by default in both layouts — Accept was previously a filled primary button next to an outline Reject, which visually nudges toward accepting. Both buttons now share the same outline treatment. This also retires two `--tome-color-*` custom properties that were never defined in the `@wabbit/tome-ui` token vocabulary (`--tome-color-muted-foreground`, `--tome-color-primary-foreground`): the description text now reads `--tome-color-on-surface-muted`, and the primary-filled button treatment that referenced the other undefined token is gone along with the equal-weight change, not just re-pointed. Fix: the banner's fixed outer wrapper no longer intercepts clicks on page content outside the card/bar (`pointer-events: none`, re-enabled to `auto` on the card/bar itself). All existing consent storage, callbacks, and ARIA role/label behavior is unchanged. The presentational markup is now split into an exported `CookieConsentPanel` (same props minus `showDelay`, plus `onAccept`/`onReject`) so it renders deterministically in a render test without needing to fake the visibility-gating effects.

  • 020b41f: `CookieConsent` gains an optional `layout?: 'panel' | 'bar'` prop (default `'panel'`, unchanged from today), plus equal-weight Accept/Reject buttons. `'bar'` renders a full-width slim strip pinned to the bottom instead of the centered card — description and both buttons share one row when there's room, wrapping onto a compact second row on phone widths via flex-wrap rather than a fixed breakpoint. Fix: Accept and Reject now have equal visual weight by default in both layouts — Accept was previously a filled primary button next to an outline Reject, which visually nudges toward accepting. Both buttons now share the same outline treatment. This also retires two `--tome-color-*` custom properties that were never defined in the `@wabbit/tome-ui` token vocabulary (`--tome-color-muted-foreground`, `--tome-color-primary-foreground`): the description text now reads `--tome-color-on-surface-muted`, and the primary-filled button treatment that referenced the other undefined token is gone along with the equal-weight change, not just re-pointed. Fix: the banner's fixed outer wrapper no longer intercepts clicks on page content outside the card/bar (`pointer-events: none`, re-enabled to `auto` on the card/bar itself). All existing consent storage, callbacks, and ARIA role/label behavior is unchanged. The presentational markup is now split into an exported `CookieConsentPanel` (same props minus `showDelay`, plus `onAccept`/`onReject`) so it renders deterministically in a render test without needing to fake the visibility-gating effects.
v1.20.0minor

8729196: New `@wabbit/tome-core/registry/themeRegistry` (theme discovery), `/registry/theme` (the `ThemeManifest` type) and `/theme-config` (a platform ThemeConfig global and injector). - `/registry/theme` — type-only `ThemeManifest`, `ThemePalette`, `ThemeFonts`, `ThemeKind` (`'theme' | 'treatment'`): the one descriptor every theme package exports. Themes activate with `data-tome-theme="<name>"`, palettes with `data-tome-palette`. - `/registry/themeRegistry` — `registerTheme`, `getTheme`, `listThemes({ kind })`, `resolveThemeName` (maps a legacy `data-tome-pack` value to its theme), `validateThemeManifest`, on the `globalThis`-anchored `createKeyedRegistry` (duplicate policy `replace`, so HMR re-registration is safe; a different package claiming a registered name warns in development). Also re-exported from `/registry`. - `/theme-config` — `createThemeConfig({ mode })` returns the `themeConfig` Payload global: theme and palette selects fed by the registry, a typography group (pairing, per-role display/body/mono overrides, display weight → `--tome-type-weight-display`), radius, and the Layer-1 color groups. `'light-first'` keeps the classic `regularColors`/`darkmodeColors` field names, so existing documents need no migration. Color fields accept hex, rgb(a), hsl(a) and `oklch()`. `renderThemeConfigCss(doc, { themes })` renders the `<style id="theme-config">` body: with no theme active its output is byte-identical to the classic starter injector; with a theme active it emits only fields changed from their defaults, under a selector that outranks every theme and palette rule. `themeConfigHtmlAttributes(doc)` returns the `<html>` attributes.

  • 8729196: New `@wabbit/tome-core/registry/themeRegistry` (theme discovery), `/registry/theme` (the `ThemeManifest` type) and `/theme-config` (a platform ThemeConfig global and injector). - `/registry/theme` — type-only `ThemeManifest`, `ThemePalette`, `ThemeFonts`, `ThemeKind` (`'theme' | 'treatment'`): the one descriptor every theme package exports. Themes activate with `data-tome-theme="<name>"`, palettes with `data-tome-palette`. - `/registry/themeRegistry` — `registerTheme`, `getTheme`, `listThemes({ kind })`, `resolveThemeName` (maps a legacy `data-tome-pack` value to its theme), `validateThemeManifest`, on the `globalThis`-anchored `createKeyedRegistry` (duplicate policy `replace`, so HMR re-registration is safe; a different package claiming a registered name warns in development). Also re-exported from `/registry`. - `/theme-config` — `createThemeConfig({ mode })` returns the `themeConfig` Payload global: theme and palette selects fed by the registry, a typography group (pairing, per-role display/body/mono overrides, display weight → `--tome-type-weight-display`), radius, and the Layer-1 color groups. `'light-first'` keeps the classic `regularColors`/`darkmodeColors` field names, so existing documents need no migration. Color fields accept hex, rgb(a), hsl(a) and `oklch()`. `renderThemeConfigCss(doc, { themes })` renders the `<style id="theme-config">` body: with no theme active its output is byte-identical to the classic starter injector; with a theme active it emits only fields changed from their defaults, under a selector that outranks every theme and palette rule. `themeConfigHtmlAttributes(doc)` returns the `<html>` attributes.
v1.19.1patch

dde86fb: Two people with the same name can now both sign up. `createMemberCollection` wires a new built-in `beforeValidate` hook, `ensureUniqueMemberSlug`, that gives a taken member slug the lowest free numeric suffix on create (`jane-doe` → `jane-doe-2`). Before this, the second member with a given display name failed the unique slug index inside `createMemberOnSignup`, which aborted the signup after the auth user was written and left that email unable to sign in. Updates are untouched, and the hook is only wired when the collection has a slug field. Also exported from `@wabbit/tome-core/identity`.

  • dde86fb: Two people with the same name can now both sign up. `createMemberCollection` wires a new built-in `beforeValidate` hook, `ensureUniqueMemberSlug`, that gives a taken member slug the lowest free numeric suffix on create (`jane-doe` → `jane-doe-2`). Before this, the second member with a given display name failed the unique slug index inside `createMemberOnSignup`, which aborted the signup after the auth user was written and left that email unable to sign in. Updates are untouched, and the hook is only wired when the collection has a slug field. Also exported from `@wabbit/tome-core/identity`.
v1.19.0minor

45f349a: `asPayloadTask`'s task handler now accepts Payload's own task arguments, so `task.handler({ input, req })` with a real `PayloadRequest` needs no cast. The handler takes `input: unknown` and `req: PayloadRequest`, and `retries` is Payload's `number | RetryConfig`. Previously the handler typed `req` as `{ payload } & Record<string, unknown>`, which a real `PayloadRequest` does not satisfy, and `input` as a record, which Payload's per-task `unknown` input does not satisfy; calling `task.handler({ input, req })` directly needed a cast. `PayloadTaskLike` and `PayloadTaskOptions` change accordingly. The generic plumbing stays loose on purpose: there is no task-slug type parameter and the handler still resolves to `{ output: unknown }`, because those types key off a site's generated jobs map, which a layer package cannot see. At runtime, an object `input` is forwarded to your `JobHandler` exactly as before; a non-object `input` (which Payload does not produce) now reaches the handler as `undefined`. Who could see a compile error: code that calls `task.handler(...)` directly with a hand-built `req` that is not typed as a `PayloadRequest` (typically a test stub — cast it to `PayloadRequest`), and code that passes a `retries` value outside Payload's `number | RetryConfig` (Payload's own task config would reject it). Registering the task and calling it with Payload's real arguments are unaffected, and every in-repo producer typechecks unchanged.

  • 45f349a: `asPayloadTask`'s task handler now accepts Payload's own task arguments, so `task.handler({ input, req })` with a real `PayloadRequest` needs no cast. The handler takes `input: unknown` and `req: PayloadRequest`, and `retries` is Payload's `number | RetryConfig`. Previously the handler typed `req` as `{ payload } & Record<string, unknown>`, which a real `PayloadRequest` does not satisfy, and `input` as a record, which Payload's per-task `unknown` input does not satisfy; calling `task.handler({ input, req })` directly needed a cast. `PayloadTaskLike` and `PayloadTaskOptions` change accordingly. The generic plumbing stays loose on purpose: there is no task-slug type parameter and the handler still resolves to `{ output: unknown }`, because those types key off a site's generated jobs map, which a layer package cannot see. At runtime, an object `input` is forwarded to your `JobHandler` exactly as before; a non-object `input` (which Payload does not produce) now reaches the handler as `undefined`. Who could see a compile error: code that calls `task.handler(...)` directly with a hand-built `req` that is not typed as a `PayloadRequest` (typically a test stub — cast it to `PayloadRequest`), and code that passes a `retries` value outside Payload's `number | RetryConfig` (Payload's own task config would reject it). Registering the task and calling it with Payload's real arguments are unaffected, and every in-repo producer typechecks unchanged.
v1.18.0minor

189bf0d: New subpath `@wabbit/tome-core/utilities/optionalIcon` exporting `attachOptionalIcon(entry, iconName)`. It fills an admin-nav manifest entry's `icon` from `lucide-react` through a lazy import, so a layer that wants a specific sidebar icon still registers on an install without `lucide-react`; the sidebar then falls back to the nav domain's default icon. `lucide-react` is declared as an optional peer. Layers that carried their own copy of this helper now import it from core.

  • 189bf0d: New subpath `@wabbit/tome-core/utilities/optionalIcon` exporting `attachOptionalIcon(entry, iconName)`. It fills an admin-nav manifest entry's `icon` from `lucide-react` through a lazy import, so a layer that wants a specific sidebar icon still registers on an install without `lucide-react`; the sidebar then falls back to the nav domain's default icon. `lucide-react` is declared as an optional peer. Layers that carried their own copy of this helper now import it from core.
  • ee874fb: `initializeRoles` no longer pauses for one second before creating missing roles, so a first boot on an empty database seeds roles without the delay. The pause had no recorded reason and guarded nothing: concurrent workers seeding the same database are handled by the unique-conflict path, which treats a lost race as "role already exists".
  • 06f34c0: `initializeRoles` now runs its per-role existence precheck with bounded concurrency instead of one read at a time. Behavior is unchanged: a failed read still fails initialization. Internal: the identity helpers type collection slugs through core's own `typedSlug()` instead of inline casts.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v1.17.0minor

67eb3dc: `@wabbit/tome-core/utilities/relationId` gains the readers the 48 local copies needed (2026-09-24 audit A3 #7). All additions are additive. `relationIdRaw(value)` returns the id in its original type (`string | number | null`) for call sites that compare against a raw `doc.id` or write back to a numeric column. `relationIds(value)` and `relationIdsRaw(value)` read hasMany values; a single value is read as a one-element list. A `{ polymorphic: true }` option on every reader also unwraps `{ relationTo, value }`. `relationId(value)` behaves exactly as before. Core's `field-reports` lifecycle drops its private `idOf` for `relationIdRaw`.

  • 67eb3dc: `@wabbit/tome-core/utilities/relationId` gains the readers the 48 local copies needed (2026-09-24 audit A3 #7). All additions are additive. `relationIdRaw(value)` returns the id in its original type (`string | number | null`) for call sites that compare against a raw `doc.id` or write back to a numeric column. `relationIds(value)` and `relationIdsRaw(value)` read hasMany values; a single value is read as a one-element list. A `{ polymorphic: true }` option on every reader also unwraps `{ relationTo, value }`. `relationId(value)` behaves exactly as before. Core's `field-reports` lifecycle drops its private `idOf` for `relationIdRaw`.
  • 30bdd74: New core homes for three helpers that were copied across layers (2026-09-24 audit A3 #16): - `autoSlugFieldHook(sourceField)` in `./fields/slug`: the field-level auto-slug hook that accounts and org carried byte-identically. - Duck-typed role-slug readers in `./auth/rbac`: `readRoles`, `slugsFromArray`, `slugFromEntry`, `roleSlugsMatchTiers`, and `resolveRoleSlugs(req, { cacheKey, userCollection })`, previously copied between lms and gamification. - `ownOrCapabilityAccess(capability, ownerField)` in `./auth/repScoping`: the own-or-operator Access that crowdfund and fulfillment each carried. **Behavior change, deliberate:** `identity`'s `autoSlugFromDisplayName` now uses `formatSlug`, the stricter slugifier, instead of its private `toSlug`. The two agree on ordinary input. On irregular whitespace the output changes: `' Ada Lovelace '` now gives `'ada-lovelace'` instead of `'--ada--lovelace-'`, and a tab becomes a hyphen instead of being deleted. Stored slugs are untouched. `createMemberCollection` has used `formatSlug` since 2026-07.
  • 4aff301: License heartbeat: an `.npmrc` credential is now expanded like npm does (`${VAR}` from the environment), and a value that is empty or still contains a `${` placeholder after expansion is treated as "no credential" — nothing is sent, and a debug line is logged. Previously the committed `//npm.wabbit.com/:_authToken=${NPM_TOKEN}` line was hashed LITERALLY whenever `NPM_TOKEN` was unset at runtime (the normal case for a deployed container, since the token is a build-time secret), so every such site reported the same bogus license hash. `buildHeartbeatPayload` accepts an optional `debug` sink.
v1.16.4patch

3cf73f4: `bunnyCdnStorage()` now passes `BUNNY_REGION` (or the `env.region` override) through as `storage.region`. It was declared in the options type but never read, so zones outside the default region got 401s from the unprefixed storage endpoint; consumers had to merge the region in by hand.

  • 3cf73f4: `bunnyCdnStorage()` now passes `BUNNY_REGION` (or the `env.region` override) through as `storage.region`. It was declared in the options type but never read, so zones outside the default region got 401s from the unprefixed storage endpoint; consumers had to merge the region in by hand.
v1.16.3patch

3b80ce7: **`createBetterAuth({ admin: { requiredRole: null } })` now actually disables the LoginView's client-side role check.** The option's contract said `null` "skips the client check entirely (server check still enforced)", and its type accepted `null`, but the factory handed the LoginView `resolvedRequiredRole ?? undefined`. The view declares `requiredRole = 'admin'` as a default parameter, so `undefined` re-enabled the check with a slug Tome never issues. Worse, the view runs that check on the sign-in _response_ user, which has no `role` field (customSession enriches only `/get-session`), so a consumer that opted out with `null` still saw "Access Denied" on every submit and had to navigate to `/admin` by hand. A production consumer carried that workaround from 2026-08-23 until a domain cutover on 2026-09-19 forced a fresh sign-in and surfaced it again. `null` now passes through as `null`; the plugin's `login.requiredRole` type declares `string | string[] | null`, and its `checkUserRoles` returns true for a falsy requirement. The server-side `Users.access.admin` gate is unchanged and remains the real lock. The default (option unset) still resolves to `DEFAULT_ADMIN_ROLES`.

  • 3b80ce7: **`createBetterAuth({ admin: { requiredRole: null } })` now actually disables the LoginView's client-side role check.** The option's contract said `null` "skips the client check entirely (server check still enforced)", and its type accepted `null`, but the factory handed the LoginView `resolvedRequiredRole ?? undefined`. The view declares `requiredRole = 'admin'` as a default parameter, so `undefined` re-enabled the check with a slug Tome never issues. Worse, the view runs that check on the sign-in _response_ user, which has no `role` field (customSession enriches only `/get-session`), so a consumer that opted out with `null` still saw "Access Denied" on every submit and had to navigate to `/admin` by hand. A production consumer carried that workaround from 2026-08-23 until a domain cutover on 2026-09-19 forced a fresh sign-in and surfaced it again. `null` now passes through as `null`; the plugin's `login.requiredRole` type declares `string | string[] | null`, and its `checkUserRoles` returns true for a falsy requirement. The server-side `Users.access.admin` gate is unchanged and remains the real lock. The default (option unset) still resolves to `DEFAULT_ADMIN_ROLES`.
v1.16.2patch

d5a63c7: `checkRoleAsync`, `checkRole` and `hasRole` now accept readonly role lists and consumer-defined slugs without a cast. `checkRoleAsync` and `checkRole` took `RoleSlug[] | string[]`, a mutable array, and `hasRole` took `(RoleSlug | string)[]`. A site that keeps one `as const` list for its admin gates, like tome-starter's and other consumer sites' `ADMIN_PANEL_ROLE_SLUGS`, cannot pass that list without `as unknown as string[]`. Keeping that list single is the point: `Users.access.admin` and `createBetterAuth`'s `requiredRole` have to read the same one so the two gates never drift apart. 1.16.1 already widened `requiredRole` the same way. All three now take `readonly (RoleSlug | string)[]`. That is a strict widening: every existing caller still compiles, and runtime behavior is unchanged. The functions only read the list. `checkRoleAsync` and `checkRole` stay `@deprecated` in favor of `canAsync`/`can` from `@wabbit/tome-core/auth/capabilities`. This change only removes a cast from existing call sites; it does not revive them for new code.

  • d5a63c7: `checkRoleAsync`, `checkRole` and `hasRole` now accept readonly role lists and consumer-defined slugs without a cast. `checkRoleAsync` and `checkRole` took `RoleSlug[] | string[]`, a mutable array, and `hasRole` took `(RoleSlug | string)[]`. A site that keeps one `as const` list for its admin gates, like tome-starter's and other consumer sites' `ADMIN_PANEL_ROLE_SLUGS`, cannot pass that list without `as unknown as string[]`. Keeping that list single is the point: `Users.access.admin` and `createBetterAuth`'s `requiredRole` have to read the same one so the two gates never drift apart. 1.16.1 already widened `requiredRole` the same way. All three now take `readonly (RoleSlug | string)[]`. That is a strict widening: every existing caller still compiles, and runtime behavior is unchanged. The functions only read the list. `checkRoleAsync` and `checkRole` stay `@deprecated` in favor of `canAsync`/`can` from `@wabbit/tome-core/auth/capabilities`. This change only removes a cast from existing call sites; it does not revive them for new code.
v1.16.1patch

ef4058c: `createBetterAuth({ admin: { requiredRole } })` now accepts consumer-defined role slugs, and readonly lists, without a cast. The option was typed as Tome's closed `RoleSlug` union, which cannot express a site's own admin-capable role. tome-starter's read-only `viewer` role hit this. Its two admin gates, `Users.access.admin` and the BetterAuth LoginView's `requiredRole`, both read one `as const` list, `ADMIN_PANEL_ROLE_SLUGS`. They have to, because widening only one of them is what locked the demo viewer out after a successful login. Passing that list to `requiredRole` needed `as unknown as RoleSlug[]`, which also turned off type checking on the line. `requiredRole` is now `readonly AdminRoleSlug[] | AdminRoleSlug | null`, where the newly exported `AdminRoleSlug` is `RoleSlug | (string & {})`. Built-in slugs still autocomplete, and non-string values are still rejected. The LoginView underneath already took plain strings, and `hasRole` in `rbac.ts` already accepted `RoleSlug | string`. A readonly list is copied into the mutable array the LoginView props declare. Type-only for existing callers; the runtime default is unchanged. Consumers can delete the `as unknown as RoleSlug[]` cast after upgrading. A typo in a custom slug is no longer caught by the compiler, which is the same tradeoff `hasRole` already makes.

  • ef4058c: `createBetterAuth({ admin: { requiredRole } })` now accepts consumer-defined role slugs, and readonly lists, without a cast. The option was typed as Tome's closed `RoleSlug` union, which cannot express a site's own admin-capable role. tome-starter's read-only `viewer` role hit this. Its two admin gates, `Users.access.admin` and the BetterAuth LoginView's `requiredRole`, both read one `as const` list, `ADMIN_PANEL_ROLE_SLUGS`. They have to, because widening only one of them is what locked the demo viewer out after a successful login. Passing that list to `requiredRole` needed `as unknown as RoleSlug[]`, which also turned off type checking on the line. `requiredRole` is now `readonly AdminRoleSlug[] | AdminRoleSlug | null`, where the newly exported `AdminRoleSlug` is `RoleSlug | (string & {})`. Built-in slugs still autocomplete, and non-string values are still rejected. The LoginView underneath already took plain strings, and `hasRole` in `rbac.ts` already accepted `RoleSlug | string`. A readonly list is copied into the mutable array the LoginView props declare. Type-only for existing callers; the runtime default is unchanged. Consumers can delete the `as unknown as RoleSlug[]` cast after upgrading. A typo in a custom slug is no longer caught by the compiler, which is the same tradeoff `hasRole` already makes.
v1.16.0minor

bc386c0: Fix a silent Payload collection-slug collision between `createBetterAuth()` and `createAccountsLayer()` that broke every sign-up on a consumer mounting both at their defaults. **Root cause:** better-auth's internal `account` model and `@wabbit/tome-accounts`' tenant Account collection both default to the Payload slug `accounts`. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose required `name`/`slug`/`ownerMember` fields (from tome-accounts) were never supplied by better-auth's own `linkAccount`/`createAccount` calls — every `POST /api/auth/sign-up/email` returned a 500 naming those fields, not the collision that caused them. **The fix (`@wabbit/tome-core`):** - `registerLayer` (`utilities/layerRegistry`) now checks a newly-registering layer's `collections` against every already-registered layer's `collections` and throws a new `LayerCollectionSlugCollisionError` — naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside a `isLayerCollectionSlugCollisionError` type guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it. - `createBetterAuth()` now registers the Payload collection slugs it mounts (`@wabbit/tome-core-auth`) with this guard, and its own try/catch re-throws a collision instead of swallowing it. - New opt-in `internalModelNames.account` on `createBetterAuth()` renames better-auth's internal account model (e.g. `{ account: 'authAccount' }` → Payload slug `authAccounts`) — the auth-side resolution when a collision fires. Default is unchanged (`accounts`), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existing `accounts` table/collection first — see the option's JSDoc. **The fix (`@wabbit/tome-accounts`):** `createAccountsLayer()`'s existing `registerLayer` try/catch now re-throws a genuine `LayerCollectionSlugCollisionError` instead of swallowing it as a duplicate-registration no-op. The layer's existing `slugs: { accounts: '<other-slug>' }` override is the accounts-side resolution — pick ONE side, not both. **Backward compatibility:** a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (`accounts` on each side) are unchanged; the guard only fires when two layers genuinely collide.

  • bc386c0: Fix a silent Payload collection-slug collision between `createBetterAuth()` and `createAccountsLayer()` that broke every sign-up on a consumer mounting both at their defaults. **Root cause:** better-auth's internal `account` model and `@wabbit/tome-accounts`' tenant Account collection both default to the Payload slug `accounts`. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose required `name`/`slug`/`ownerMember` fields (from tome-accounts) were never supplied by better-auth's own `linkAccount`/`createAccount` calls — every `POST /api/auth/sign-up/email` returned a 500 naming those fields, not the collision that caused them. **The fix (`@wabbit/tome-core`):** - `registerLayer` (`utilities/layerRegistry`) now checks a newly-registering layer's `collections` against every already-registered layer's `collections` and throws a new `LayerCollectionSlugCollisionError` — naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside a `isLayerCollectionSlugCollisionError` type guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it. - `createBetterAuth()` now registers the Payload collection slugs it mounts (`@wabbit/tome-core-auth`) with this guard, and its own try/catch re-throws a collision instead of swallowing it. - New opt-in `internalModelNames.account` on `createBetterAuth()` renames better-auth's internal account model (e.g. `{ account: 'authAccount' }` → Payload slug `authAccounts`) — the auth-side resolution when a collision fires. Default is unchanged (`accounts`), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existing `accounts` table/collection first — see the option's JSDoc. **The fix (`@wabbit/tome-accounts`):** `createAccountsLayer()`'s existing `registerLayer` try/catch now re-throws a genuine `LayerCollectionSlugCollisionError` instead of swallowing it as a duplicate-registration no-op. The layer's existing `slugs: { accounts: '<other-slug>' }` override is the accounts-side resolution — pick ONE side, not both. **Backward compatibility:** a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (`accounts` on each side) are unchanged; the guard only fires when two layers genuinely collide.
v1.15.0minor

b081304: New `@wabbit/tome-core/license` subpath: `licenseHeartbeat()`, an `onInit`-compatible export (wire it alongside `initRoles`, same pattern) that reports a deployed site's presence to wabbit's licensing desk — D10's exact shape, `{ licenseHash, host, tomeCoreVersion, nodeEnv, families }`, sent once at boot and then daily via an `unref()`'d interval so it never blocks or holds open the process. `licenseHash` is the sha256 of the deploy credential the build installed with, resolved from the same sources npm itself would use (`NPM_TOKEN` env, then the project `.npmrc`'s `//npm.wabbit.com/:_authToken`, then `~/.npmrc`) — the raw token is never sent, and the response is never read. `families` is the distinct `wabbit.family` values of every installed `@wabbit/tome-*` package, read from each one's own `node_modules` manifest and cached per boot. Report-only by contract: there is no runtime denial path, on this end or any other — a heartbeat cannot disable anything. Silent by default (opt out with `TOME_LICENSE_HEARTBEAT=off`), a 5s timeout, and every failure swallowed to at most one `debug`-level log line. `buildHeartbeatPayload(env, opts)` is exported alongside it as a pure, network-free helper for anything that wants to inspect the payload shape without sending it.

  • b081304: New `@wabbit/tome-core/license` subpath: `licenseHeartbeat()`, an `onInit`-compatible export (wire it alongside `initRoles`, same pattern) that reports a deployed site's presence to wabbit's licensing desk — D10's exact shape, `{ licenseHash, host, tomeCoreVersion, nodeEnv, families }`, sent once at boot and then daily via an `unref()`'d interval so it never blocks or holds open the process. `licenseHash` is the sha256 of the deploy credential the build installed with, resolved from the same sources npm itself would use (`NPM_TOKEN` env, then the project `.npmrc`'s `//npm.wabbit.com/:_authToken`, then `~/.npmrc`) — the raw token is never sent, and the response is never read. `families` is the distinct `wabbit.family` values of every installed `@wabbit/tome-*` package, read from each one's own `node_modules` manifest and cached per boot. Report-only by contract: there is no runtime denial path, on this end or any other — a heartbeat cannot disable anything. Silent by default (opt out with `TOME_LICENSE_HEARTBEAT=off`), a 5s timeout, and every failure swallowed to at most one `debug`-level log line. `buildHeartbeatPayload(env, opts)` is exported alongside it as a pure, network-free helper for anything that wants to inspect the payload shape without sending it.
  • 8fd56ad: New in `@wabbit/tome-core/field-reports` (Locations wave): `createLocationReportCollection`, a SUBJECT-SCOPED report with a reviewed lifecycle and a denormalised read tier — a sibling of `createFieldReportCollection`, not a variant of it. One required option, `subjectRelationTo`; everything else is a seam: `statuses`, `transitionTable`, `tiers`, `initialStatus`, `timestampOnEnter`, the stored field names, and the resolvers for actor, author, subject tier, viewer tier and per-subject grants. Full house seam set — `extraFields` → `fieldOverrides` (rename via `name`) → `omitFields` → `fieldOrder`, per-verb `access` injection merged over the defaults one key at a time, `mergeHooks` for consumer hooks, `labels`, relation slugs, `slug`. The five behaviors the donor collection ran as one 100-line `beforeChange` are split into named built-ins — `statusForcing`, `authorStamping`, `contentEditGate`, `transitionGate`, `tierDerivation` — each individually opt-out-able via `builtInHooks`, so a consumer can replace one without forking the collection. `effectiveReportTier(tiers, subjectTier, reportVisibility, fallback?)` and `stricterTier(tiers, a, b)` ship as pure, Payload-free exports: a report may be MORE restrictive than its subject, never less. The read gate is built from `resolveViewerTier(req)` with an optional `grantsResolver(req, subjectId)`, treats tiers as a cumulative loosest-first ladder, keeps the legacy-row arm for documents written before the derived field existed, and FAILS CLOSED on a viewer tier outside `tiers`. The lifecycle table contract lives in the same subpath — `ReportTransitionTable`, `resolveReportTransition`, `findReportTransitions`, `allowedReportTransitionsFrom`, `notSelfReporter`, `notReportSubmitter` — and is structurally identical to `@wabbit/tome-workflow`'s, so a real `WorkflowTransitionTable` assigns with no cast and that package's own `resolveTransition` can be injected through the `transitionResolver` option. Core declares its own copy rather than importing `@wabbit/tome-workflow`, which already depends on core; the module header records the reasoning and what a future wave should do instead. Lifecycle server API, also on `./field-reports`: `transitionReport` (one report, returns a structured outcome rather than throwing — `transitioned` / `refused` with what WAS reachable / `no-op` / `not-found`), `reDeriveTier` (recompute the denormalised tier after a subject is reclassified; one read per distinct subject, writes only rows that actually changed, fails closed to the strictest tier on an unreadable subject) and `markStale` (batch sweep that puts every row through the transition table and COUNTS refusals instead of forcing them). All three drive the collection's own gates through the Payload local API with the actor injected on `req.context` (`LOCATION_REPORT_ACTOR_CONTEXT_KEY`, `systemTransition`) rather than reaching around them, and the bulk paths go through `batchWrite` + `findPaged` so a per-row failure is isolated and reported instead of aborting the run. No clearance, game-version or intel vocabulary anywhere in the module — every consumer-specific concept is a resolver or a config value.
v1.14.0minor

ce3d12d: Two more primitives the audit found copied across layers, promoted with their forcing function (2026-09-01 sale-readiness audit §5.1, T3(g)). New public surface: - **`@wabbit/tome-core/fields/address`** (new subpath) — `postalAddressGroup(opts)`, `postalAddressFields(opts)`, `mapAddress` / `mapAddressToLegacy`, the `AddressVocabulary` type and the two field-name constants. The same six-field postal block was hand-rolled in FIVE places across three layers (`@wabbit/tome-crm` accounts + contacts, `@wabbit/tome-deals` billing + shipping, `@wabbit/tome-fulfillment` `Addresses`) in TWO INCOMPATIBLE VOCABULARIES: `address1/address2/city/state/zip/country` and `line1/line2/city/region/postalCode/country`. Two vocabularies for one concept guarantees a mapping layer at the CRM → shipping seam, and that mapping did not exist anywhere. **This module does not pick a winner, and no stored field name changes.** Both vocabularies are stored shapes with live rows behind them — renaming `zip` to `postalCode` in crm is a data migration, not a refactor. Every adopter passes the `vocabulary` it already stores. `required` marks the four load-bearing lines only (never `line2`, never `state`/`region` — requiring a state makes the schema US-shaped, which fulfillment's own comment argues against at length). `validateCountry` carries fulfillment's ISO-3166 alpha-2 check and its uppercase-normalizing `beforeValidate` hook across verbatim; normalizing rather than rejecting lowercase is deliberate, because the shipping rate table matches on this value and a rejected `"us"` teaches nothing. `fieldOverrides` merges per-sub-field changes through the existing `/fields/fieldShape` seam, which is how deals keeps its eight per-field labels and its `'US'` country default without a second copy of the block. `postalAddressFields` exists alongside the group because fulfillment stores the postal lines FLAT at collection top level, interleaved with `recipientName` and `phone`. Wrapping them in a group to reuse the group helper would have been exactly the stored-shape change this promotion refuses to make. - **`@wabbit/tome-core/utilities/relationId`** (new subpath) — `relationId(value): string | null` and `relationIdOrThrow(value, label)`. The audit counted this read in core's deprecated `lms` tree, lms (twelve copies under two names), sc, ledger, crowdfund, fulfillment, lms-ui and an inline ternary in crm. The finding was not the count but **four different return types**: `string | null`, `string | number | null`, `string | undefined`, `string | number | undefined`. Two call sites resolving the same row could disagree about equality — a populated doc's numeric id arriving unstringified next to a bare id string. Core picks `string | null` and stringifies: `String(id)` for both string and number, `null` (never `undefined`) for absent, `null` for an array (a `hasMany` value is the caller's loop, not a silent first-element read). Payload accepts either form in a `where` clause, so ids fed back into a query are unaffected, and `===` between two resolved ids now means what a reader thinks it means. A caller that genuinely needs the id in its stored type should not use this — that is a documented divergence rather than a fifth accidental copy. Forcing function: `scripts/assert-no-forked-primitives.mjs` gains a fourth, STRUCTURAL check. Checks 1–3 compare code, and the address fork is invisible to all three because it is not code — it is five object literals whose sub-field name sets are the same schema typed out by hand. The new check finds every `type: 'group'` field literal, reads the direct sub-field names out of its `fields:` array, and warns when that set COVERS either vocabulary. Coverage rather than equality on purpose: exact equality would have caught none of the five real copies, since crm prepends `name` and deals prepends `name` + `company`. Run on the pre-adoption tree it reports all four group-shaped copies (crm accounts:89, crm contacts:135, deals:192 and :207); on the adopted tree it reports none. `@wabbit/tome-ledger` keeps its own `extractId`, for the same architectural reason as its `mergeHooks` (core is an OPTIONAL peer there) plus a second one: its semantics differ deliberately (`string | undefined`, string-only input). That exemption is recorded as prose above the assert's ALLOWLIST rather than as an entry, because the ALLOWLIST is FILE-granular — an entry for a nine-line function inside a 130-line module would never match and would print as STALE on every run forever, training readers to ignore the stale report. That is the failure mode `assert-test-floor.mjs` already demonstrated for seven weeks.

  • ce3d12d: Two more primitives the audit found copied across layers, promoted with their forcing function (2026-09-01 sale-readiness audit §5.1, T3(g)). New public surface: - **`@wabbit/tome-core/fields/address`** (new subpath) — `postalAddressGroup(opts)`, `postalAddressFields(opts)`, `mapAddress` / `mapAddressToLegacy`, the `AddressVocabulary` type and the two field-name constants. The same six-field postal block was hand-rolled in FIVE places across three layers (`@wabbit/tome-crm` accounts + contacts, `@wabbit/tome-deals` billing + shipping, `@wabbit/tome-fulfillment` `Addresses`) in TWO INCOMPATIBLE VOCABULARIES: `address1/address2/city/state/zip/country` and `line1/line2/city/region/postalCode/country`. Two vocabularies for one concept guarantees a mapping layer at the CRM → shipping seam, and that mapping did not exist anywhere. **This module does not pick a winner, and no stored field name changes.** Both vocabularies are stored shapes with live rows behind them — renaming `zip` to `postalCode` in crm is a data migration, not a refactor. Every adopter passes the `vocabulary` it already stores. `required` marks the four load-bearing lines only (never `line2`, never `state`/`region` — requiring a state makes the schema US-shaped, which fulfillment's own comment argues against at length). `validateCountry` carries fulfillment's ISO-3166 alpha-2 check and its uppercase-normalizing `beforeValidate` hook across verbatim; normalizing rather than rejecting lowercase is deliberate, because the shipping rate table matches on this value and a rejected `"us"` teaches nothing. `fieldOverrides` merges per-sub-field changes through the existing `/fields/fieldShape` seam, which is how deals keeps its eight per-field labels and its `'US'` country default without a second copy of the block. `postalAddressFields` exists alongside the group because fulfillment stores the postal lines FLAT at collection top level, interleaved with `recipientName` and `phone`. Wrapping them in a group to reuse the group helper would have been exactly the stored-shape change this promotion refuses to make. - **`@wabbit/tome-core/utilities/relationId`** (new subpath) — `relationId(value): string | null` and `relationIdOrThrow(value, label)`. The audit counted this read in core's deprecated `lms` tree, lms (twelve copies under two names), sc, ledger, crowdfund, fulfillment, lms-ui and an inline ternary in crm. The finding was not the count but **four different return types**: `string | null`, `string | number | null`, `string | undefined`, `string | number | undefined`. Two call sites resolving the same row could disagree about equality — a populated doc's numeric id arriving unstringified next to a bare id string. Core picks `string | null` and stringifies: `String(id)` for both string and number, `null` (never `undefined`) for absent, `null` for an array (a `hasMany` value is the caller's loop, not a silent first-element read). Payload accepts either form in a `where` clause, so ids fed back into a query are unaffected, and `===` between two resolved ids now means what a reader thinks it means. A caller that genuinely needs the id in its stored type should not use this — that is a documented divergence rather than a fifth accidental copy. Forcing function: `scripts/assert-no-forked-primitives.mjs` gains a fourth, STRUCTURAL check. Checks 1–3 compare code, and the address fork is invisible to all three because it is not code — it is five object literals whose sub-field name sets are the same schema typed out by hand. The new check finds every `type: 'group'` field literal, reads the direct sub-field names out of its `fields:` array, and warns when that set COVERS either vocabulary. Coverage rather than equality on purpose: exact equality would have caught none of the five real copies, since crm prepends `name` and deals prepends `name` + `company`. Run on the pre-adoption tree it reports all four group-shaped copies (crm accounts:89, crm contacts:135, deals:192 and :207); on the adopted tree it reports none. `@wabbit/tome-ledger` keeps its own `extractId`, for the same architectural reason as its `mergeHooks` (core is an OPTIONAL peer there) plus a second one: its semantics differ deliberately (`string | undefined`, string-only input). That exemption is recorded as prose above the assert's ALLOWLIST rather than as an entry, because the ALLOWLIST is FILE-granular — an entry for a nine-line function inside a 130-line module would never match and would print as STALE on every run forever, training readers to ignore the stale report. That is the failure mode `assert-test-floor.mjs` already demonstrated for seven weeks.
  • 04309f5: Add `batchWrite` / `batchWriteInChunks` — the platform's bounded-concurrency batch writer — at the new `@wabbit/tome-core/utilities/batch` subpath. The 2026-09-01 sale-readiness audit found **15 serial-await-in-loop sites** across `@wabbit/tome-org`, `@wabbit/tome-lms` and `@wabbit/tome-sc`: Payload `afterChange`/`afterDelete` hooks and reconciler jobs fanning out N independent writes one `await` at a time, so an event with 200 attendees blocked the request for 200 sequential round-trips. In every case the correct pattern already existed a few files away in the same package — `division-team-reciprocity.ts:101-108` batched while `:88-99` did not; `gdpr.ts` used one bulk update in one handler and a per-row loop in its sibling. The primitive was never extracted, so nobody adopted it, so it kept getting rewritten wrong. - `batchWrite(items, fn, { concurrency = 8, onError, logger, label })` — bounded-concurrency mapper. `results` is index-aligned with `items` regardless of completion order, and a per-item throw is isolated into `errors` instead of poisoning its siblings (the failure mode that makes a naive `Promise.all` unsafe in a cascade hook, where one missing related doc must not abort the other 199 updates). `onError: 'throw'` reproduces a serial loop's exact abort semantics for callers that want them. - `batchWriteInChunks(items, fn, { chunkSize = 25, pauseMs = 250, shouldStop, ... })` — the same, plus the `WRITE_CHUNK`/`WRITE_PAUSE_MS` pacing invented in `@wabbit/tome-lms`'s `reconcileCourseCompletionAwards`: process a chunk, then pause, so a write's own `afterChange` fan-out has room to drain before the next chunk lands. `shouldStop` carries a per-run write budget without abandoning the pacing. The module header states what it is NOT for: a bulk `payload.update({ where, data })` beats any amount of concurrency when every item takes the same data, and a deliberately ordered loop (money capture, ledger legs, `settleCampaign`'s `maxCapturesPerRun` counter) stays sequential — pass `concurrency: 1` when you want the pacing and error isolation but must keep strict ordering. Shipped with its forcing function: a `no-restricted-syntax` rule in `eslint.config.mjs` warns on `await payload.*` as a direct statement inside a `for…of`/`for` body and points here.
  • 4aeedad: `createKeyedRegistry` in core, and the gate that keeps the next registry anchored. Tome had eleven keyed registries and two implementations of one idea: five anchored their state on `globalThis` via `Symbol.for`, six held a module-local `Map` (2026-09-01 sale-readiness audit §5.1, "same mechanism, half correct"). The half that is wrong is wrong silently. A published package ships separate ESM and CJS builds — distinct module instances with distinct module-local state — so the moment one consumer static-imports one build and another `require()`s the other, or a bundler splits an RSC/SSR/client graph, a module-local `Map` exists twice and a registration made through one is invisible through the other. Nothing throws; the handler just never fires. `layerRegistry` shipped that bug in 2026-05 and moved onto `globalThis` in tome-core 1.0.10, both it and the render registry explain the mechanism at length in their headers, and six registries were written afterwards without it. A comment cannot make the next author read it. **New in core:** `@wabbit/tome-core/registry/createKeyedRegistry` (a NEW exports-map subpath — hence the minor). `createKeyedRegistry<T>(symbolKey, { onDuplicate, validate })` returns `{ register, replace, get, has, list, clear }` over a store anchored at `globalThis[Symbol.for(symbolKey)]`. `onDuplicate` is `'throw'` (default) / `'replace'` / `'ignore'`, chosen to match each migrating registry's CURRENT behavior rather than a preferred one. `replace()` is the explicit override path every throw-on-duplicate registry in the repo already exposed for tests and consumer shadowing. The module's JSDoc carries the full migration recipe for the registries not migrated here. 14 unit tests, including the dual-instantiation proof: two separately-created registries on one key share a store, and the state survives a `vi.resetModules()` re-evaluation of the defining module — a module-local `Map` fails both. **Migrated in core:** `gdpr/registry.ts`. This one had BOTH halves of the defect — a module-local `Map` inside `GdprRegistryImpl`, and absence from core's own `sideEffects` array — while four layers (fulfillment, org, sc, plus consumer sites) register into it by import side effect. Split state meant `runErasure`/`exportUserData` reporting zero rows for collections registered into the other copy; a missing `sideEffects` entry meant a bundler was free to drop the registering module outright. The store is now anchored (`onDuplicate: 'replace'`, matching `registerCollection`'s documented overwrite) and `./dist/gdpr/registry.*` is in `sideEffects`, with a `sideEffectsRationale` block in the manifest recording why each entry is there. The class API is unchanged — same names, arguments, semantics, and `getAll()`'s registration-order guarantee. `unregisterCollection` rebuilds the store minus one key (the helper exposes no per-key delete because nothing else needs one), preserving that order. **Migrated in deals:** both registries. `registry/side-effect-registry.ts` is now a delegation shim over `@wabbit/tome-workflow`'s registry (see the workflow-adoption changeset) — anchored by that route. `registry/artifact-registry.ts` moves onto `createKeyedRegistry`, INCLUDING its `frozen` flag: a freeze applied to one module instance while another still accepted registrations would have enforced the config-time contract in exactly half the process. Public API, throws and messages are unchanged. This registry is populated in `payload.config.ts` and read during collection construction, and under the Payload CLI those are separate module instances — the observable failure was an `artifactType` select with no options and a thrown "Unknown artifact type". **Forcing function:** `scripts/assert-registry-anchoring.mjs` + `pnpm assert:registry-anchoring`, wired into `platform-discipline.yml` immediately after `assert:no-forked-primitives` (source + manifest reading only, so it runs pre-build and fails fast). Any module-scope mutable `Map`/`Set`/instance singleton whose name — or whose FILE name — announces a registry must import `createKeyedRegistry`, contain `Symbol.for(`, or have its built path listed in the package's `sideEffects` array; otherwise it fails with the migration recipe. Before this change it reported 3 violations (core's gdpr registry and both deals registries) and now reports 0. Eight registries are ALLOWLISTED with a written architectural reason each, not a schedule: forms ×3, intake and print are owned by the forms+intake access wave and their file sets are off-limits to this one; `blocks-core/src/registry/index.ts` is the deliberately explicit-instance DESCRIPTOR registry (ARCHITECTURE.md § Three Registry Mechanisms #2 — the registry that genuinely must be one store, the render registry, is separately `Symbol.for`-anchored and passes), and changing it is a twelve-package linked-family decision; blocks-gallery's two are import-side-effect registries its own header already calls "the outlier, not the template", in a package with zero tests, so they migrate in the wave that gives it tests.
  • 8fc9702: Adopt the three primitives that four other layers had each copied verbatim, and add the gate that stops the next copy (2026-09-01 sale-readiness audit §5.1, T3(a)). New public surface: - **`@wabbit/tome-core/jobs`** gains `findPaged`, `readPositiveNumber`, `chunk`, `JOB_PAGE_SIZE`, `JOB_DEFAULT_MAX_PAGES` and the `FindPagedArgs` type — the bounded paginated job scan that existed identically in `@wabbit/tome-lms`, `@wabbit/tome-crowdfund` and `@wabbit/tome-workflow`. Each of those three headers named this exact promotion as its own trigger condition; crowdfund's said "if a third layer needs it, it is promoted into `@wabbit/tome-core/jobs` and both call sites collapse". Workflow became the third layer on 2026-08-18. No behavior change: the body is the lms/crowdfund version, with the collection slug now passed through core's own `typedSlug()` rather than an inline cast. The `./jobs` subpath already existed — these are new named exports on it, so a consumer importing them needs core `>=1.14.0`. - **`@wabbit/tome-core/fields/fieldShape`** (new subpath) — `applyFieldShape`, `applyFieldOverrides`, `applyOmitFields`, `applyFieldOrder`, `insertFieldsAfter`, plus the `FieldOverrideMap` and `FieldShapeConfig` types. The canonical body is `@wabbit/tome-ledger`'s, the superset (it is the only copy that had the `applyFieldShape` combinator). Where the three copies had drifted they had drifted only in two error-message strings; the longer wording is kept, because it names the most common cause of the error ("check for a `fieldNames` rename applied before this"). - **`@wabbit/tome-core/fields/selectOptions`** (new subpath) — the ONE `{ mode: 'extend' | 'replace'; options }` select-vocabulary override contract, with `extend` deduped by `value`. The audit found this contract implemented five times under one name in three incompatible shapes, so the same config key behaved differently depending on which layer emitted the field. Core's own `field-reports/shared.ts` and `gdpr/compliance/processingRegister.ts` now import it and re-export their existing symbols unchanged. Deprecated, not removed: `SelectOptionOverride` in `@wabbit/tome-core/identity` (the `{ extend?, replace? }` shape) now carries `@deprecated` pointing at the canonical contract. It is public API on a 1.x package and keeps working — `MemberCollectionConfig.identity.standingOptions` is unaffected — but nothing new should adopt it. New gate: `pnpm assert:no-forked-primitives` (`scripts/assert-no-forked-primitives.mjs`), wired into `platform-discipline.yml` immediately after `assert:declared-imports` (source-only, pre-build). It hashes comment-stripped, import-stripped file bodies across every `packages/*/src/**/*.ts` and fails on a non-core file identical to a core file, or on two non-core files identical to each other. A second, function-granular check warns on near-forks — two files sharing at least two identically-named exported functions whose bodies are ≥90% identical — which is how the `fieldShape` trio would have been caught before it drifted. One allowlist entry, `@wabbit/tome-ledger`'s `mergeHooks`, with the architectural reason stated in full: ledger declares core as an OPTIONAL peer. The failure mode this closes is not "we forgot to DRY this up". Four files in the repo stated their own promotion trigger in prose, the trigger fired, and nothing happened, because a comment is not a gate.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 0836ef5: `fields/link` no longer throws "(0, import_deepMerge.default) is not a function" when required from CommonJS. The internal `deepMerge` helper was a `default` export consumed via a default import inside the package; with tsup `bundle: false` that compiles to `__toESM(require(...), 1).default` — the whole module object, not the function — so any CJS consumer of `@wabbit/tome-core/fields/link` (tome-chrome's header factory under raw Node or the Payload CLI) failed at the first `link()` call. ESM consumers were unaffected, which is why it went unnoticed until `assert:node-loadable` ran across every package. `deepMerge` is now a named export; the rule (named exports for intra-package modules, default only for React components) is recorded in docs/claude-gotchas.md.
  • 73081e6: `infra/envScaffold` now covers the whole platform, not core's own third of it. `validateEnv()` checked 10 variables; the platform reads about 22, and every paid integration was outside the check — Stripe, Encharge, Kit, Resend, `AI_CREDENTIAL_KEY`, `TOME_ADMIN_LICENSE_KEY`. That is not a boot crash, it is a checkout that 500s or a webhook that verifies nothing, in production, weeks after the deploy that caused it. New `LAYER_ENV_VARS`, keyed by package name so it takes the same identifiers as `hasLayer()`, covering economy, crowdfund, marketing, ai, admin-pro, deals, intake, crm and print. `validateEnv({ layers })` merges the named blocks on top of `SHARED_ENV_VARS`; when two layers declare the same variable the strictest requirement wins and the descriptions are joined, so a site running both economy and crowdfund gets one `STRIPE_SECRET_KEY` line carrying both reasons. An unknown layer name throws rather than contributing nothing — a typo there would silently check less than the caller believes. Backwards compatible: `validateEnv()` with no argument checks exactly the set it always did, so no existing `payload.config.ts` changes behavior. `EnvVarSpec` and the new `ValidateEnvOptions` are exported. `required` here means "required GIVEN this layer is installed", and the distinction is real rather than cosmetic: the Stripe pair and `TOME_ADMIN_LICENSE_KEY` are hard failures, while the marketing keys stay warnings because a site runs Encharge **or** Kit **or** neither and making them fatal would break a legitimate deployment. `RESEND_API_KEY` is likewise a warning — its absence selects a supported log-instead-of-send mode. One real bug fell out of the survey: `SHARED_ENV_VARS` and `env.example` both documented **`POSTHOG_HOST`**, while `infra/posthog.tsx` has always read **`NEXT_PUBLIC_POSTHOG_HOST`**. A site that set the documented name got the `/ingest` reverse-proxy default and no error. The scaffold now names the variable the code reads; `env.example` gains the per-layer block with the same required/optional annotations. 13 new tests in `tests/infra/envScaffold.test.ts`.
  • 670d2a1: **`infra/plugins.ts` no longer types the five Payload plugin wrappers `(config: any)`.** This is the one place every Tome site wires redirects, SEO, form-builder, search and nested-docs, and it was the audit's headline "load-bearing `any`" (§6): a misconfiguration here was invisible to `tsc` for every consumer at once. Each constructor's parameter is now recovered as `Parameters<typeof pluginFn>[0]` through a **type-only** import of the plugin package. Type-only because the constructors are still passed in by the consuming site — importing them for real would reintroduce the ESM/CJS resolution problem that shape exists to avoid — and `import type` erases entirely at build time, so this adds zero runtime coupling. `Parameters<...>` rather than a named config import because the plugin packages export only their constructor from the barrel; the config interfaces live in unexported `./types.js` modules. A side benefit: the types track whichever plugin version the consumer resolved instead of a snapshot copied into this file. All five packages are already declared `peerDependencies` of core, so nothing is added to the manifest. New exported types: `RedirectsPluginOptions`, `SeoPluginOptions`, `FormBuilderPluginOptions`, `SearchPluginOptions`, `NestedDocsPluginOptions`. `search.beforeSync` is typed from the plugin's own config instead of `(args: any) => any`. The stricter types immediately found one real seam: nested-docs' `generateURL` takes four arguments and untyped docs, while Tome's option has always been the narrower `(docs: {slug}[]) => string`. Widening the public option would break every consumer (the narrow callback is not assignable to the wider parameter under `strictFunctionTypes`), so the public shape is unchanged and `basePlugins` now adapts between the two explicitly, defaulting a missing `slug` to `''` instead of emitting `/undefined` — the runtime bug the `any` had been hiding. `.d.ts` note: the emitted `dist/infra/plugins.d.ts` now references the `@payloadcms/plugin-*` types. Consumers already install these to pass the constructors in, and they are declared peers; a consumer with `skipLibCheck` (the Payload default) is unaffected either way.
  • fa0491f: `validateEnv({ layers })` now knows about `TOME_CRM_BOOTSTRAP_READ_FALLBACK`, the flag crm 0.6.0 introduced to re-open the bootstrap `crm:read` bridge that is otherwise off in production. It is optional, so nothing fails without it; the point is that an operator reading the per-layer env schema to answer "what does installing the CRM oblige me to configure?" now sees the switch that governs whether unseeded sites expose contact and account PII, instead of discovering it from a log warning after the fact.
  • 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.
  • 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source has carried since the permissions-naming convergence pass.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
v1.13.1patch

f2b849c: Wave 6 org-adoption I4.1 — closes the `identity.displayNameRequired` gap the first `createMemberCollection` consumer (a production consumer, 1395 rows, 0 with `displayName`) hit, and grows the factory's generic field coverage. All opt-in, defaults byte-identical. **`identity.includeDisplayName?: boolean` (default `true`).** `displayNameRequired` only ever toggled the field's `required` flag — there was no way to omit `displayName` entirely while keeping the factory's slug handling. `skipDefaultIdentity: true` was the only escape, but it drops `displayName` AND the `slug` pair TOGETHER, forcing that consumer's real adapter to re-supply its own slug pair verbatim via `extraFields` just to keep a factory-composed slug. `includeDisplayName: false` closes the gap: no `displayName` field is emitted, `admin.useAsTitle`/`defaultColumns` fall back to `identity.titleField` (now REQUIRED in that case — the factory throws `MemberIdentityConfigError` at collection-definition time otherwise), `slugSource` defaults to `titleField` instead of `'displayName'`, and the `slug` field itself is still emitted by default (pass `slugField: false` to also drop it, same as today). **Six generic member-table fields, each opt-in and default off** — the factory only ever emitted 4 of that consumer's 62 `members` fields (`user`/`avatar`/`bio`/`standing`); these are the ones judged generic enough for any member table to plausibly want, from auditing what that consumer's real adapter supplied via `extraFields`: - `includeClassification` — `'public' | 'classified'` select, default `'public'` (`DEFAULT_MEMBER_CLASSIFICATION_OPTIONS` exported). - `includePortrait` — upload field (relationTo `mediaCollection`), distinct from the always-on `avatar`. - `includeReportsTo` — self-relationship (relationTo the collection's own `slug`) for chain-of-command data. - `includeTimezone` + `timezoneField` (default `'timezone'`) — plain `text` field, not a curated select (the factory doesn't own a timezone-options list); rename to match an existing column (e.g. `timeZone`). - `includeRegion` — six-value coarse-geography select (`DEFAULT_MEMBER_REGION_OPTIONS`: NA/EU/APAC/OCE/SA/MEA). - `includeJoinDate` + `joinDateField` (default `'joinDate'`) — plain `date` field. Does NOT replicate a nested onboarding/induction group shape some consumers may use instead. None of these byte-match any one consumer's existing field shape (different value casings, no curated timezone list) — generic defaults a future consumer can take as-is, rename where a `*Field` option is offered, or still fully override via `extraFields`. Also: `admin.defaultColumns`' first entry now reads `titleField` instead of a hardcoded `'displayName'` literal, so it never references a dropped field when `includeDisplayName` is `false` (unchanged value when `includeDisplayName` is `true`, since `titleField` still defaults to `'displayName'`). New `MemberIdentityConfigError` exported from `@wabbit/tome-core/identity`. New tests in `tests/identity/createMemberCollection.test.ts` (2 new describe blocks, 20 new cases); the pre-existing "default snapshot" block is untouched and green.

  • f2b849c: Wave 6 org-adoption I4.1 — closes the `identity.displayNameRequired` gap the first `createMemberCollection` consumer (a production consumer, 1395 rows, 0 with `displayName`) hit, and grows the factory's generic field coverage. All opt-in, defaults byte-identical. **`identity.includeDisplayName?: boolean` (default `true`).** `displayNameRequired` only ever toggled the field's `required` flag — there was no way to omit `displayName` entirely while keeping the factory's slug handling. `skipDefaultIdentity: true` was the only escape, but it drops `displayName` AND the `slug` pair TOGETHER, forcing that consumer's real adapter to re-supply its own slug pair verbatim via `extraFields` just to keep a factory-composed slug. `includeDisplayName: false` closes the gap: no `displayName` field is emitted, `admin.useAsTitle`/`defaultColumns` fall back to `identity.titleField` (now REQUIRED in that case — the factory throws `MemberIdentityConfigError` at collection-definition time otherwise), `slugSource` defaults to `titleField` instead of `'displayName'`, and the `slug` field itself is still emitted by default (pass `slugField: false` to also drop it, same as today). **Six generic member-table fields, each opt-in and default off** — the factory only ever emitted 4 of that consumer's 62 `members` fields (`user`/`avatar`/`bio`/`standing`); these are the ones judged generic enough for any member table to plausibly want, from auditing what that consumer's real adapter supplied via `extraFields`: - `includeClassification` — `'public' | 'classified'` select, default `'public'` (`DEFAULT_MEMBER_CLASSIFICATION_OPTIONS` exported). - `includePortrait` — upload field (relationTo `mediaCollection`), distinct from the always-on `avatar`. - `includeReportsTo` — self-relationship (relationTo the collection's own `slug`) for chain-of-command data. - `includeTimezone` + `timezoneField` (default `'timezone'`) — plain `text` field, not a curated select (the factory doesn't own a timezone-options list); rename to match an existing column (e.g. `timeZone`). - `includeRegion` — six-value coarse-geography select (`DEFAULT_MEMBER_REGION_OPTIONS`: NA/EU/APAC/OCE/SA/MEA). - `includeJoinDate` + `joinDateField` (default `'joinDate'`) — plain `date` field. Does NOT replicate a nested onboarding/induction group shape some consumers may use instead. None of these byte-match any one consumer's existing field shape (different value casings, no curated timezone list) — generic defaults a future consumer can take as-is, rename where a `*Field` option is offered, or still fully override via `extraFields`. Also: `admin.defaultColumns`' first entry now reads `titleField` instead of a hardcoded `'displayName'` literal, so it never references a dropped field when `includeDisplayName` is `false` (unchanged value when `includeDisplayName` is `true`, since `titleField` still defaults to `'displayName'`). New `MemberIdentityConfigError` exported from `@wabbit/tome-core/identity`. New tests in `tests/identity/createMemberCollection.test.ts` (2 new describe blocks, 20 new cases); the pre-existing "default snapshot" block is untouched and green.
v1.13.0minor

d2347e3: Wave 6 org plan, W6-I4 — `createMemberCollection` (`@wabbit/tome-core/identity`) grows the config surface a consumer's `members` collection needs to host it, upstream only: nothing is adopted here, and every option is additive with a default that reproduces the pre-I4 output byte-for-byte (a new "default snapshot" test block asserts this, kept green through the change). - **`requireUser` (default `true`) + `userValidate`:** the `user` relationship can now be optional — a production consumer's `members` collection has 1395 rows, 1167 with `user: null` (all `standing: 'legacy'`). `unique: true` stays on the field either way; Payload's unique index is sparse for optional fields, so multiple `null` rows don't collide. `userValidate` is wired only when `requireUser: false`. - **`identity` config (`displayNameRequired`, `titleField`, `slugSource`, `slugField`):** lets a consumer make `displayName` optional, point `admin.useAsTitle` at a different field (e.g. a consumer's `rsiHandle`), and source the generated slug from that field instead — or supply their own `slugField(...)` pair entirely (`slugField: Field[]`), or drop the slug field while keeping `displayName` (`slugField: false`). This composes WITHOUT `skipDefaultIdentity`, which still drops both `displayName` and `slug` unchanged, exactly as before. - **`standingOptions` (`true | { extend?, replace? }`) + `standingDefault`:** adds an opt-in `standing` select field. Omitted by default — the pre-I4 factory has no `standing` field at all, and `@wabbit/tome-org`'s `Member` wrapper still supplies its own via `extraFields` today, unchanged by this release. `DEFAULT_MEMBER_STANDING_OPTIONS` exports the 8 values org hardcodes today, so a later increment can reference the same base set instead of re-typing it. - **`defaultPopulate` / `indexes` passthrough:** the factory did not spread unknown config into the returned `CollectionConfig` — both are now explicit passthroughs, present in the output only when provided. - **`defaultReadAccess` (`'authenticated' | 'self-or-admin' | Access`):** lets a caller choose the `read` strategy without replacing the whole `access` object (which would also drop the create/update/delete defaults). Payload's `Access` type already supports a `Where`-returning function (sync or async) — Contract C2 — so a custom function honors a consumer's `membersReadAccess` verbatim. Ignored when `access` is provided; `access` always fully replaces, unchanged. - **Hook merge, every key:** `config.hooks` used to merge `beforeValidate` by hand (built-in first, caller appended) but spread every other key wholesale — a caller-supplied `afterChange` would have silently REPLACED a future built-in `afterChange` hook the moment one was added, the same bug independently found and fixed in `@wabbit/tome-org` and `@wabbit/tome-sc`. New shared `@wabbit/tome-core/hooks/mergeHooks` leaf subpath (ported verbatim from `packages/org/src/hooks/mergeHooks.ts` / `packages/sc/src/extensions/mergeHooks.ts`) generalizes the append semantics across every hook key; `org`/`sc` can migrate their local copies to this one in a later increment. Read-only consumer verification: `tome-starter` and `wabbit-site-core` call `createMemberCollection` with none of the new options (unaffected); `@wabbit/tome-org`'s `Member.ts` wrapper typechecks clean against the new signature; `@wabbit/tome-lms` does not consume this factory.

  • d2347e3: Wave 6 org plan, W6-I4 — `createMemberCollection` (`@wabbit/tome-core/identity`) grows the config surface a consumer's `members` collection needs to host it, upstream only: nothing is adopted here, and every option is additive with a default that reproduces the pre-I4 output byte-for-byte (a new "default snapshot" test block asserts this, kept green through the change). - **`requireUser` (default `true`) + `userValidate`:** the `user` relationship can now be optional — a production consumer's `members` collection has 1395 rows, 1167 with `user: null` (all `standing: 'legacy'`). `unique: true` stays on the field either way; Payload's unique index is sparse for optional fields, so multiple `null` rows don't collide. `userValidate` is wired only when `requireUser: false`. - **`identity` config (`displayNameRequired`, `titleField`, `slugSource`, `slugField`):** lets a consumer make `displayName` optional, point `admin.useAsTitle` at a different field (e.g. a consumer's `rsiHandle`), and source the generated slug from that field instead — or supply their own `slugField(...)` pair entirely (`slugField: Field[]`), or drop the slug field while keeping `displayName` (`slugField: false`). This composes WITHOUT `skipDefaultIdentity`, which still drops both `displayName` and `slug` unchanged, exactly as before. - **`standingOptions` (`true | { extend?, replace? }`) + `standingDefault`:** adds an opt-in `standing` select field. Omitted by default — the pre-I4 factory has no `standing` field at all, and `@wabbit/tome-org`'s `Member` wrapper still supplies its own via `extraFields` today, unchanged by this release. `DEFAULT_MEMBER_STANDING_OPTIONS` exports the 8 values org hardcodes today, so a later increment can reference the same base set instead of re-typing it. - **`defaultPopulate` / `indexes` passthrough:** the factory did not spread unknown config into the returned `CollectionConfig` — both are now explicit passthroughs, present in the output only when provided. - **`defaultReadAccess` (`'authenticated' | 'self-or-admin' | Access`):** lets a caller choose the `read` strategy without replacing the whole `access` object (which would also drop the create/update/delete defaults). Payload's `Access` type already supports a `Where`-returning function (sync or async) — Contract C2 — so a custom function honors a consumer's `membersReadAccess` verbatim. Ignored when `access` is provided; `access` always fully replaces, unchanged. - **Hook merge, every key:** `config.hooks` used to merge `beforeValidate` by hand (built-in first, caller appended) but spread every other key wholesale — a caller-supplied `afterChange` would have silently REPLACED a future built-in `afterChange` hook the moment one was added, the same bug independently found and fixed in `@wabbit/tome-org` and `@wabbit/tome-sc`. New shared `@wabbit/tome-core/hooks/mergeHooks` leaf subpath (ported verbatim from `packages/org/src/hooks/mergeHooks.ts` / `packages/sc/src/extensions/mergeHooks.ts`) generalizes the append semantics across every hook key; `org`/`sc` can migrate their local copies to this one in a later increment. Read-only consumer verification: `tome-starter` and `wabbit-site-core` call `createMemberCollection` with none of the new options (unaffected); `@wabbit/tome-org`'s `Member.ts` wrapper typechecks clean against the new signature; `@wabbit/tome-lms` does not consume this factory.
  • 4d0ef26: Close three `./field-reports` gaps the first adoption attempt by a production consumer hit (Wave 2R I4.1), all additive with unchanged defaults: `createWatchlistGlobal` gains `fieldNames: { organizations?, externalId?, name?, category?, notes? }` (the array field name was previously hardcoded to `organizations`; `externalIdField` stays as a deprecated alias); `createRiskScorer` gains `watchlistFieldNames` (reads the watchlist through the SAME names `createWatchlistGlobal` was given, instead of piggybacking on the assessment collection's `fieldNames.affiliationExternalId`) and `flagNames` (overrides every emitted flag string — `multiAffiliation`/`hiddenProfile`/`redactedProfile`/`newAccount`/`manualFlag`/`categoryFlag` — threaded through `score`, `assessAndStore`, and `runReassessRisk` via the new `resolveFlagNames` export); `registerFieldReportsGdpr` gains `reports`/`assessments` options, each `false` (skip registering that collection) or `{ phase?, order? }` (reposition it), replacing "the later registration silently overwrites the earlier one by slug" as the only way to coexist with a consumer's own pinned registration. A new "consumer-compatible config" test block in `scorer.test.ts` ports every case from that consumer's characterized `scoreThreat.spec.ts` and confirms identical scores/flags (one pre-existing divergence carries over: the single `visibility` enum here splits the consumer's combined 40-point "hostile + hidden + redacted" case into two 30-point assertions).
v1.12.0minor

6091eba: Add `./field-reports` subpath (Wave 2R / 2R-I4): subject watchlist + scored risk assessment + free-text field reports, absorbed from a consumer's intel cluster. `createFieldReportCollection`, `createSubjectAssessmentCollection` (fully overridable stored field names via `fieldNames` — zero migration), `createWatchlistGlobal`, `createRiskScorer` (`score`/`assessAndStore`, injectable weights/bands/thresholds, case-insensitive upsert), `inputFromProfile`, the standard task/endpoint adapter pair `createReassessRiskTask`/`createReassessRiskEndpoint` (cache-only rescore), and `registerFieldReportsGdpr` (reports `null-ref`; assessments conditional `redact` per the retain-with-basis decision).

  • 6091eba: Add `./field-reports` subpath (Wave 2R / 2R-I4): subject watchlist + scored risk assessment + free-text field reports, absorbed from a consumer's intel cluster. `createFieldReportCollection`, `createSubjectAssessmentCollection` (fully overridable stored field names via `fieldNames` — zero migration), `createWatchlistGlobal`, `createRiskScorer` (`score`/`assessAndStore`, injectable weights/bands/thresholds, case-insensitive upsert), `inputFromProfile`, the standard task/endpoint adapter pair `createReassessRiskTask`/`createReassessRiskEndpoint` (cache-only rescore), and `registerFieldReportsGdpr` (reports `null-ref`; assessments conditional `redact` per the retain-with-basis decision).
v1.11.0minor

cb8739b: Add `@wabbit/tome-core/verification` — neutral external-profile-verification primitives absorbed from a consumer's external-profile hardening work (Wave 2R / 2R-I1): `createProfileVerificationFlow` (HMAC rolling-window ownership-proof codes + signed tokens), `createDistributedRateLimiter` + `createInMemoryLimiterClient` (fail-closed by default — a deliberate inversion of the always-fail-open behavior it was absorbed from), `createResilientFetcher` (retry/backoff, explicit `retryOn` status policy defaulting to `429`/`>=500` with `Retry-After` honored and capped at `timeoutMs` — never throws on an HTTP status), the `ExternalProfileAdapter<TProfile>` contract, `createProfileCacheCollection` + `createProfileCacheInvalidator`, and `runProfileReconcile` with the standard task/endpoint adapter pair `createProfileReconcileTask`/`createProfileReconcileEndpoint`. Additive only — nothing in the monorepo consumes this subpath yet (`RSIProfileAdapter` lands in `@wabbit/tome-sc` at 2R-I2).

  • cb8739b: Add `@wabbit/tome-core/verification` — neutral external-profile-verification primitives absorbed from a consumer's external-profile hardening work (Wave 2R / 2R-I1): `createProfileVerificationFlow` (HMAC rolling-window ownership-proof codes + signed tokens), `createDistributedRateLimiter` + `createInMemoryLimiterClient` (fail-closed by default — a deliberate inversion of the always-fail-open behavior it was absorbed from), `createResilientFetcher` (retry/backoff, explicit `retryOn` status policy defaulting to `429`/`>=500` with `Retry-After` honored and capped at `timeoutMs` — never throws on an HTTP status), the `ExternalProfileAdapter<TProfile>` contract, `createProfileCacheCollection` + `createProfileCacheInvalidator`, and `runProfileReconcile` with the standard task/endpoint adapter pair `createProfileReconcileTask`/`createProfileReconcileEndpoint`. Additive only — nothing in the monorepo consumes this subpath yet (`RSIProfileAdapter` lands in `@wabbit/tome-sc` at 2R-I2).
v1.10.1patch

ad65127: `createProcessingRegisterCollection` (`@wabbit/tome-core/gdpr/compliance`, Wave 5 / I7.1) gains override options for its three fixed-vocabulary `select` fields — `dataCategoryOptions?`, `dataSubjectOptions?`, `securityMeasureOptions?: { mode: 'extend' | 'replace', options: {label, value}[] }` — closing a gap the initial absorb (I7) left: a consumer migrating an existing register with vocabulary the default list doesn't cover (`dataCategories: fleet`, `dataSubjects: veterans` — renamed `alumni` on the way in — and four `securityMeasures` values) had no way to keep it. `extend` appends the consumer's options after the default vocabulary, deduped by `value` (default wins on collision); `replace` substitutes wholesale, the same full-replace contract `access` already uses. The three default arrays (`DEFAULT_DATA_CATEGORY_OPTIONS`, `DEFAULT_DATA_SUBJECT_OPTIONS`, `DEFAULT_SECURITY_MEASURE_OPTIONS`) are now exported so a consumer can compose rather than retype them. Also adds `userCollection?: string` (default `'users'`) for `lastReviewedBy`'s `relationTo`, mirroring `createConsentLedgerCollection`'s option of the same name — it was the one relationship field in this factory not already injectable. Fully backward compatible: a caller passing none of the four new options gets today's fields, byte-identical.

  • ad65127: `createProcessingRegisterCollection` (`@wabbit/tome-core/gdpr/compliance`, Wave 5 / I7.1) gains override options for its three fixed-vocabulary `select` fields — `dataCategoryOptions?`, `dataSubjectOptions?`, `securityMeasureOptions?: { mode: 'extend' | 'replace', options: {label, value}[] }` — closing a gap the initial absorb (I7) left: a consumer migrating an existing register with vocabulary the default list doesn't cover (`dataCategories: fleet`, `dataSubjects: veterans` — renamed `alumni` on the way in — and four `securityMeasures` values) had no way to keep it. `extend` appends the consumer's options after the default vocabulary, deduped by `value` (default wins on collision); `replace` substitutes wholesale, the same full-replace contract `access` already uses. The three default arrays (`DEFAULT_DATA_CATEGORY_OPTIONS`, `DEFAULT_DATA_SUBJECT_OPTIONS`, `DEFAULT_SECURITY_MEASURE_OPTIONS`) are now exported so a consumer can compose rather than retype them. Also adds `userCollection?: string` (default `'users'`) for `lastReviewedBy`'s `relationTo`, mirroring `createConsentLedgerCollection`'s option of the same name — it was the one relationship field in this factory not already injectable. Fully backward compatible: a caller passing none of the four new options gets today's fields, byte-identical.
v1.10.0minor

8eaae2e: New `./gdpr/compliance` and `./gdpr/compliance/breach` subpaths (Wave 5 / W5-I7): three optional, consumer-opt-in compliance artefacts absorbed from a production consumer, none of them wired into any existing gdpr consumer — nothing changes for a site that does not import this subpath. - **`createProcessingRegisterCollection(options)`** — an Article 30 processing register factory (neutral default slug `processing-records`, overridable). The absorb win: `collectionsInvolved` is now a `select` whose options are DERIVED from `gdprRegistry.getAll()` at factory-call time (plus a free-text `otherCollections` escape hatch), replacing the consumer's free-text field that had already drifted from what was actually registered. `access` is injected via an option — the default uses only core's generic `admins`/`authenticated` guards, never a hardcoded consumer-specific permission key. `validateAgainstRegistry(rows, registrations?)` cross-checks in both directions: register rows naming a collection no longer registered (stale), and registered collections no register row covers (undocumented processing). - **`createConsentLedgerCollection(options)`** — an immutable (`update: () => false`, not overridable) consent ledger factory: `user`, `action` (select, extensible via `actions`), `policyVersion`, `ipHash`, `userAgent`, `metadata`, `at`. Collapses the consumer's `consentType` + `granted` boolean + `grantedAt`/`revokedAt` pair into one generic `action` + `at` — the specific consent taxonomy is a consumer's `extraFields`, not core's business. Auto-registers itself with `gdprRegistry` as `mode: 'retain'` by default (`registerGdpr: false` to opt out) — it IS the erasure proof (Art 7(1)), never deleted regardless of the rest of a consumer's cascade. Plus pure helpers `requiresReconsent(consentedVersion, currentVersion)` (major-version-only comparison, no module-level constant) and `recordConsent(payload, input, options?)` (Local API, `overrideAccess: true`). - **`./gdpr/compliance/breach`** — `assessBreachSeverity`, `generateAuthorityNotification`, `generateSubjectNotification`, `createBreachRecord`, ported from a consumer's `breachNotification.ts` (5 exports, ZERO call sites in production — absorbed as untested prior art, not hardened code; these are its first tests, full stop). Two real fixes made during the port: org identity (`organizationName`/`dpoContact`) is now a caller-supplied `BreachNotificationConfig` instead of hardcoded to the consumer's name/contact, and Article 33(3)(c)/34(2)'s REQUIRED "likely consequences" content is a real `report.likelyConsequences` field with an honest fallback sentence instead of a rendered `[To be assessed based on breach specifics]` placeholder. `createBreachRecord(payload, report, { slug, ... })` takes its target collection explicitly rather than assuming the consumer's `audit-logs`. 65 new unit tests: factory option overrides, `collectionsInvolved`/`validateAgainstRegistry` both directions, ledger immutability + auto-registration, the `requiresReconsent` major-version matrix, `recordConsent`'s call shape, `assessBreachSeverity`'s score-boundary matrix, and both notification templates' Article 33(3)(a-d)/34(2) required-field coverage.

  • 8eaae2e: New `./gdpr/compliance` and `./gdpr/compliance/breach` subpaths (Wave 5 / W5-I7): three optional, consumer-opt-in compliance artefacts absorbed from a production consumer, none of them wired into any existing gdpr consumer — nothing changes for a site that does not import this subpath. - **`createProcessingRegisterCollection(options)`** — an Article 30 processing register factory (neutral default slug `processing-records`, overridable). The absorb win: `collectionsInvolved` is now a `select` whose options are DERIVED from `gdprRegistry.getAll()` at factory-call time (plus a free-text `otherCollections` escape hatch), replacing the consumer's free-text field that had already drifted from what was actually registered. `access` is injected via an option — the default uses only core's generic `admins`/`authenticated` guards, never a hardcoded consumer-specific permission key. `validateAgainstRegistry(rows, registrations?)` cross-checks in both directions: register rows naming a collection no longer registered (stale), and registered collections no register row covers (undocumented processing). - **`createConsentLedgerCollection(options)`** — an immutable (`update: () => false`, not overridable) consent ledger factory: `user`, `action` (select, extensible via `actions`), `policyVersion`, `ipHash`, `userAgent`, `metadata`, `at`. Collapses the consumer's `consentType` + `granted` boolean + `grantedAt`/`revokedAt` pair into one generic `action` + `at` — the specific consent taxonomy is a consumer's `extraFields`, not core's business. Auto-registers itself with `gdprRegistry` as `mode: 'retain'` by default (`registerGdpr: false` to opt out) — it IS the erasure proof (Art 7(1)), never deleted regardless of the rest of a consumer's cascade. Plus pure helpers `requiresReconsent(consentedVersion, currentVersion)` (major-version-only comparison, no module-level constant) and `recordConsent(payload, input, options?)` (Local API, `overrideAccess: true`). - **`./gdpr/compliance/breach`** — `assessBreachSeverity`, `generateAuthorityNotification`, `generateSubjectNotification`, `createBreachRecord`, ported from a consumer's `breachNotification.ts` (5 exports, ZERO call sites in production — absorbed as untested prior art, not hardened code; these are its first tests, full stop). Two real fixes made during the port: org identity (`organizationName`/`dpoContact`) is now a caller-supplied `BreachNotificationConfig` instead of hardcoded to the consumer's name/contact, and Article 33(3)(c)/34(2)'s REQUIRED "likely consequences" content is a real `report.likelyConsequences` field with an honest fallback sentence instead of a rendered `[To be assessed based on breach specifics]` placeholder. `createBreachRecord(payload, report, { slug, ... })` takes its target collection explicitly rather than assuming the consumer's `audit-logs`. 65 new unit tests: factory option overrides, `collectionsInvolved`/`validateAgainstRegistry` both directions, ledger immutability + auto-registration, the `requiresReconsent` major-version matrix, `recordConsent`'s call shape, `assessBreachSeverity`'s score-boundary matrix, and both notification templates' Article 33(3)(a-d)/34(2) required-field coverage.
v1.9.0minor

4238d00: New `./gdpr/retention` subpath (Wave 5 / W5-I5): a pure `runRetention({ payload, policies, now?, dryRun?, lock?, findDueErasures?, onErasureDue?, onStalledErasure?, stalledAfterDays? })` retention engine, absorbed from a consumer's `gdprRetentionCleanup` task with its single-flight lock and supersede semantics carried along, ported test-for-test (74 new tests) and shipped with both standard task/endpoint adapters. - **Policy model as data:** `defineRetentionPolicies([...])` validates and normalizes `RetentionPolicy = { collection, action: 'hard-delete' | 'anonymize' | 'retain', olderThan: { field, days }, where?, batchSize?, label? }` (plus `onAnonymize`, required when `action: 'anonymize'` — new relative to the donor engine, which never anonymized anything at the retention horizon). Cutoff is strict less-than (a row exactly at the cutoff is not yet eligible). Hard-delete batching re-queries page 1 like `deleteInBatches`; anonymize batching pages by explicit page number instead, since an anonymized row usually stays matched. Both cap at 2000 batches and report hitting the cap as a policy `errors[]` entry, not just a log line. - **Erasure-due sweep:** given a caller-supplied `findDueErasures(now)` (its own query + assembled lifecycle trail) and `onErasureDue(candidate)` (loads user/member, calls `runErasure`), the engine applies the supersede check via `resolveErasureTrail` before dispatching — the `6a4c2f47` regression fixture (an erasure request re-executed 2-6 times a night for 18 nights) is now a pinned unit test, alongside "complete-then-new-request executes once." - **Single-flight lock seam:** `lock?: { acquire(key): Promise<Release | null>, onUnavailable?: 'skip' | 'run' }`. Required outside `dryRun` whenever `policies` or `findDueErasures` are non-empty — `runRetention` throws immediately rather than running unlocked by omission. Ships `createInMemoryRetentionLock()` for tests; no redis dependency added. **`onUnavailable` defaults to `'skip'` (fail CLOSED)** — this inverts the donor's `acquireDrainLock`, which fails OPEN on a Redis outage. The `6a4c2f47` incident is the reason: running this sweep unlocked is a repeatable, multi-week data-integrity failure, judged worse here than skipping one tick. A consumer under legal-deadline pressure opts back in via `onUnavailable: 'run'`. - **Officer-task escalation:** `onStalledErasure?(info)` fires once per candidate, only when it remains unresolved after `onErasureDue` was tried and is overdue past `stalledAfterDays` (default 1). - **Adapters:** `createRetentionTask(config)` → Payload `TaskHandler`, `createRetentionEndpoint(config)` → `CRON_SECRET`-guarded endpoint. Both call the identical `runRetention`. The donor consumer's 9 retention policies ship ONLY as a test fixture (`tests/gdpr/retention/fixtures/consumerPolicies.ts`) exercising this model — their slugs are that consumer's collections, not core's default policy set. See `packages/core/src/gdpr/README.md`'s `./gdpr/retention` section for the full contract.

  • 4238d00: New `./gdpr/retention` subpath (Wave 5 / W5-I5): a pure `runRetention({ payload, policies, now?, dryRun?, lock?, findDueErasures?, onErasureDue?, onStalledErasure?, stalledAfterDays? })` retention engine, absorbed from a consumer's `gdprRetentionCleanup` task with its single-flight lock and supersede semantics carried along, ported test-for-test (74 new tests) and shipped with both standard task/endpoint adapters. - **Policy model as data:** `defineRetentionPolicies([...])` validates and normalizes `RetentionPolicy = { collection, action: 'hard-delete' | 'anonymize' | 'retain', olderThan: { field, days }, where?, batchSize?, label? }` (plus `onAnonymize`, required when `action: 'anonymize'` — new relative to the donor engine, which never anonymized anything at the retention horizon). Cutoff is strict less-than (a row exactly at the cutoff is not yet eligible). Hard-delete batching re-queries page 1 like `deleteInBatches`; anonymize batching pages by explicit page number instead, since an anonymized row usually stays matched. Both cap at 2000 batches and report hitting the cap as a policy `errors[]` entry, not just a log line. - **Erasure-due sweep:** given a caller-supplied `findDueErasures(now)` (its own query + assembled lifecycle trail) and `onErasureDue(candidate)` (loads user/member, calls `runErasure`), the engine applies the supersede check via `resolveErasureTrail` before dispatching — the `6a4c2f47` regression fixture (an erasure request re-executed 2-6 times a night for 18 nights) is now a pinned unit test, alongside "complete-then-new-request executes once." - **Single-flight lock seam:** `lock?: { acquire(key): Promise<Release | null>, onUnavailable?: 'skip' | 'run' }`. Required outside `dryRun` whenever `policies` or `findDueErasures` are non-empty — `runRetention` throws immediately rather than running unlocked by omission. Ships `createInMemoryRetentionLock()` for tests; no redis dependency added. **`onUnavailable` defaults to `'skip'` (fail CLOSED)** — this inverts the donor's `acquireDrainLock`, which fails OPEN on a Redis outage. The `6a4c2f47` incident is the reason: running this sweep unlocked is a repeatable, multi-week data-integrity failure, judged worse here than skipping one tick. A consumer under legal-deadline pressure opts back in via `onUnavailable: 'run'`. - **Officer-task escalation:** `onStalledErasure?(info)` fires once per candidate, only when it remains unresolved after `onErasureDue` was tried and is overdue past `stalledAfterDays` (default 1). - **Adapters:** `createRetentionTask(config)` → Payload `TaskHandler`, `createRetentionEndpoint(config)` → `CRON_SECRET`-guarded endpoint. Both call the identical `runRetention`. The donor consumer's 9 retention policies ship ONLY as a test fixture (`tests/gdpr/retention/fixtures/consumerPolicies.ts`) exercising this model — their slugs are that consumer's collections, not core's default policy set. See `packages/core/src/gdpr/README.md`'s `./gdpr/retention` section for the full contract.
v1.8.1patch

8120ff5: GDPR `onDelete`/`onExport` handlers now receive the full `GdprStepContext` (Wave 5 / I1.1) — the same object `onRedact`/`onAnonymize`/`onNullRef` already got — instead of a bare `{ payload, userId, userEmail }`, closing the gap the first `runErasure` consumer hit: a member-keyed collection (`member-notes.member`, `wallets.owner`, `group-memberships.member`) had no `memberId` to query by, and neither handler could see the subject's pre-overwrite `identity`. Fully additive — a handler destructuring only the old three keys is unaffected. `exportUserData`'s `ExportArgs` gains optional `member?`/`identity?` to thread this through; both default to absent/`{}` for every caller today. `onDelete` may now return either a bare `number` (legacy — normalized to `{ count, errors: [] }`) or a `GdprStepOutcome` (`{ count, skipped?, errors }`), so a handler can report a partial sweep (e.g. a media cap that leaves files behind) without throwing. Throwing still forfeits the count — a rejected promise carries no return value, so a thrown error always reports `count: 0`. Verified the default (handler-less) `hard-delete` path already honored `memberField` (`buildDefaultWhere`'s `userField = userId OR memberField = memberId` OR-clause predates this change); documented that `null-ref` has no default dispatch at all and has always required `onNullRef` unconditionally, since nulling a reference needs to know which field(s) to null and to what value.

  • 8120ff5: GDPR `onDelete`/`onExport` handlers now receive the full `GdprStepContext` (Wave 5 / I1.1) — the same object `onRedact`/`onAnonymize`/`onNullRef` already got — instead of a bare `{ payload, userId, userEmail }`, closing the gap the first `runErasure` consumer hit: a member-keyed collection (`member-notes.member`, `wallets.owner`, `group-memberships.member`) had no `memberId` to query by, and neither handler could see the subject's pre-overwrite `identity`. Fully additive — a handler destructuring only the old three keys is unaffected. `exportUserData`'s `ExportArgs` gains optional `member?`/`identity?` to thread this through; both default to absent/`{}` for every caller today. `onDelete` may now return either a bare `number` (legacy — normalized to `{ count, errors: [] }`) or a `GdprStepOutcome` (`{ count, skipped?, errors }`), so a handler can report a partial sweep (e.g. a media cap that leaves files behind) without throwing. Throwing still forfeits the count — a rejected promise carries no return value, so a thrown error always reports `count: 0`. Verified the default (handler-less) `hard-delete` path already honored `memberField` (`buildDefaultWhere`'s `userField = userId OR memberField = memberId` OR-clause predates this change); documented that `null-ref` has no default dispatch at all and has always required `onNullRef` unconditionally, since nulling a reference needs to know which field(s) to null and to what value.
v1.8.0minor

bb4678b: GDPR registry gains cascade semantics needed to carry a production consumer's Article 17 erasure engine (Wave 5 / W5-I1), with zero behavior change for existing registrants. `GdprCollectionRegistration` adds optional `mode` (`'hard-delete' | 'soft-anonymize' | 'redact' | 'retain' | 'null-ref'`, default `'hard-delete'`), `phase` (`'pre-identity' | 'identity' | 'post-identity'`, default `'pre-identity'`), `order` (default `0`), `memberField` (a second FK for member-keyed collections), and mode handlers `onRedact`/`onAnonymize`/`onNullRef`. `gdprRegistry.getOrdered()` sorts by phase then order then registration sequence — a no-op for any registry containing only default-mode registrations. New `runErasure({ payload, user, member?, identity, dryRun? })` dispatches every registration on its mode and returns `{ status: 'completed' | 'completed-with-errors', steps }`, one `StepResult` per registration, with no bare `catch {}` — every failure lands in that step's `errors[]` and the run continues. `deleteAccount` is now a thin wrapper over `runErasure` that reproduces its pre-existing behavior byte-for-byte for starter/wabbit-site-core (pinned by a call-sequence parity test); `exportUserData` now iterates `getOrdered()` instead of `getAll()` (identical order for default-mode registries). Also adds `./gdpr/erasureState`: pure, zero-import cooling/due/overdue classification (`classifyErasureState`) and a generic latest-wins trail resolver (`resolveErasureTrail`) absorbed from the same consumer's erasure-lifecycle utilities, ported test-for-test.

  • bb4678b: GDPR registry gains cascade semantics needed to carry a production consumer's Article 17 erasure engine (Wave 5 / W5-I1), with zero behavior change for existing registrants. `GdprCollectionRegistration` adds optional `mode` (`'hard-delete' | 'soft-anonymize' | 'redact' | 'retain' | 'null-ref'`, default `'hard-delete'`), `phase` (`'pre-identity' | 'identity' | 'post-identity'`, default `'pre-identity'`), `order` (default `0`), `memberField` (a second FK for member-keyed collections), and mode handlers `onRedact`/`onAnonymize`/`onNullRef`. `gdprRegistry.getOrdered()` sorts by phase then order then registration sequence — a no-op for any registry containing only default-mode registrations. New `runErasure({ payload, user, member?, identity, dryRun? })` dispatches every registration on its mode and returns `{ status: 'completed' | 'completed-with-errors', steps }`, one `StepResult` per registration, with no bare `catch {}` — every failure lands in that step's `errors[]` and the run continues. `deleteAccount` is now a thin wrapper over `runErasure` that reproduces its pre-existing behavior byte-for-byte for starter/wabbit-site-core (pinned by a call-sequence parity test); `exportUserData` now iterates `getOrdered()` instead of `getAll()` (identical order for default-mode registries). Also adds `./gdpr/erasureState`: pure, zero-import cooling/due/overdue classification (`classifyErasureState`) and a generic latest-wins trail resolver (`resolveErasureTrail`) absorbed from the same consumer's erasure-lifecycle utilities, ported test-for-test.
v1.7.1patch

7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.

  • 7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
v1.7.0minor

196d642: Phase A shared contracts — six cross-layer seams, all additive. - **`/authority`** — injected `AuthorityResolver` + request-cached `getAuthority`. The platform owns the seam, shape and caching guarantee; it never implements the cascade, which is consumer org policy. `scopes` is a string-keyed map rather than named fields so consumers with differing hierarchies aren't forced to misrepresent them. - **`/access/scoped`** — `ScopedAccessResult` (`boolean | Where`), `andWhere`/`orWhere` with defined boolean short-circuits, and `whereScopedTo` bridging a resolved authority to a row constraint. It returns `false`, never `{}`, when a subject commands nothing: an empty `Where` matches every row, so "no authority" expressed as `{}` is a total access bypass. - **`/notifications`** — task-notification emitter interface (no collection). Frozen `dedupKey`/`groupKey` conventions, no-op default so layers work standalone, and emit/resolve helpers guaranteed not to throw — a notification failure must never fail the mutation it describes. - **`/jobs`** — framework-agnostic `JobHandler` plus `asPayloadTask` and `asCronEndpoint`, so layers ship logic and consumers choose a runner. Both adapters invoke the same function. Also exports `authorizedCronRequest`. - **`/config/assertRelationTargets`** — startup validator for dangling `relationTo` targets. A missed sibling slug does not error in Mongo; it returns zero rows months later. - **`registerSuperRoles` / `isSuperRoleUser`** (in `/auth/permissions`) — opt-in, empty by default, wired into all three resolution paths. With nothing registered, behavior is byte-identical to before. **Security fix:** three copies of `authorizedCronRequest` short-circuited on `authHeader.length !== expected.length` — the exact leak `utilities/timingSafeEqual` was promoted into core to eliminate. Core's two copies now delegate to the shared hash-then-compare implementation, so a wrong-length header costs the same work as a right-length one. It also rejects a blank secret outright.

  • 196d642: Phase A shared contracts — six cross-layer seams, all additive. - **`/authority`** — injected `AuthorityResolver` + request-cached `getAuthority`. The platform owns the seam, shape and caching guarantee; it never implements the cascade, which is consumer org policy. `scopes` is a string-keyed map rather than named fields so consumers with differing hierarchies aren't forced to misrepresent them. - **`/access/scoped`** — `ScopedAccessResult` (`boolean | Where`), `andWhere`/`orWhere` with defined boolean short-circuits, and `whereScopedTo` bridging a resolved authority to a row constraint. It returns `false`, never `{}`, when a subject commands nothing: an empty `Where` matches every row, so "no authority" expressed as `{}` is a total access bypass. - **`/notifications`** — task-notification emitter interface (no collection). Frozen `dedupKey`/`groupKey` conventions, no-op default so layers work standalone, and emit/resolve helpers guaranteed not to throw — a notification failure must never fail the mutation it describes. - **`/jobs`** — framework-agnostic `JobHandler` plus `asPayloadTask` and `asCronEndpoint`, so layers ship logic and consumers choose a runner. Both adapters invoke the same function. Also exports `authorizedCronRequest`. - **`/config/assertRelationTargets`** — startup validator for dangling `relationTo` targets. A missed sibling slug does not error in Mongo; it returns zero rows months later. - **`registerSuperRoles` / `isSuperRoleUser`** (in `/auth/permissions`) — opt-in, empty by default, wired into all three resolution paths. With nothing registered, behavior is byte-identical to before. **Security fix:** three copies of `authorizedCronRequest` short-circuited on `authHeader.length !== expected.length` — the exact leak `utilities/timingSafeEqual` was promoted into core to eliminate. Core's two copies now delegate to the shared hash-then-compare implementation, so a wrong-length header costs the same work as a right-length one. It also rejects a blank secret outright.
v1.6.2patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v1.6.1patch

e30c705: Auth stack unpinned to current: the April workspace override (better-auth 1.4.18 / adapter 0.3.10, added when better-auth 1.6.2 dropped the apiKey plugin export that payload-better-auth 0.3.15 still imported) is removed — the factory dropped the apiKey plugin long ago and the adapter ecosystem resolved the breakage by moving it to @better-auth/api-key. Core now builds and tests (203/203) against better-auth 1.6.26 and @delmaredigital/payload-better-auth 0.10; published peer ranges are unchanged.

  • e30c705: Auth stack unpinned to current: the April workspace override (better-auth 1.4.18 / adapter 0.3.10, added when better-auth 1.6.2 dropped the apiKey plugin export that payload-better-auth 0.3.15 still imported) is removed — the factory dropped the apiKey plugin long ago and the adapter ecosystem resolved the breakage by moving it to @better-auth/api-key. Core now builds and tests (203/203) against better-auth 1.6.26 and @delmaredigital/payload-better-auth 0.10; published peer ranges are unchanged.
v1.6.0minor

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.
v1.5.0minor

`link()` / `linkGroup()` accept a `routes` option — a third link type for pages that live in the app's route tree rather than a collection. A code-owned page has no document for the internal-link relationship to point at, so the only way an editor could reach `/support` or `/docs/get-started` was to type the path into the external-URL box — which mislabels the data and leaves nothing for a consumer's link resolver to key on when choosing between a client-side route transition and a hard navigation. The new type is backed by a **select**, not a text field: the destination list is closed, so a broken internal link cannot be authored. Consumers generate the list from their own route tree. Stored shape is `{ type: 'route', route: '/support' }`. Independent of `relationTo` — a site may offer collections, routes, or both: | Configuration | Emitted radio | | -------------------- | ------------------------------- | | collections only | `reference, custom` (unchanged) | | collections + routes | `reference, route, custom` | | routes only | `route, custom` (new) | | neither | external-URL-only (unchanged) | Route sits directly after the collection option so the two same-site destinations read as a pair; legacy type options stay last. `naming` gains `typeValues.route`, `typeLabels.route` and `routeFieldName`, matching the existing reference/url escape hatches. **Fully additive.** With `routes` omitted or empty the emitted field is identical to before — asserted directly by test.

  • `link()` / `linkGroup()` accept a `routes` option — a third link type for pages that live in the app's route tree rather than a collection. A code-owned page has no document for the internal-link relationship to point at, so the only way an editor could reach `/support` or `/docs/get-started` was to type the path into the external-URL box — which mislabels the data and leaves nothing for a consumer's link resolver to key on when choosing between a client-side route transition and a hard navigation. The new type is backed by a **select**, not a text field: the destination list is closed, so a broken internal link cannot be authored. Consumers generate the list from their own route tree. Stored shape is `{ type: 'route', route: '/support' }`. Independent of `relationTo` — a site may offer collections, routes, or both: | Configuration | Emitted radio | | -------------------- | ------------------------------- | | collections only | `reference, custom` (unchanged) | | collections + routes | `reference, route, custom` | | routes only | `route, custom` (new) | | neither | external-URL-only (unchanged) | Route sits directly after the collection option so the two same-site destinations read as a pair; legacy type options stay last. `naming` gains `typeValues.route`, `typeLabels.route` and `routeFieldName`, matching the existing reference/url escape hatches. **Fully additive.** With `routes` omitted or empty the emitted field is identical to before — asserted directly by test.
v1.4.0minor

6bc419c: Permissions convergence: capabilities (`can`/`canAsync`) are THE runtime-gate API — six flat/hierarchy-unaware checkers (`checkRole`, rbac's `checkPermission`/`checkRoleAsync`/`checkPermissionAsync`/`checkAnyPermission`/`checkAllPermissions`) are `@deprecated` (sunset core 2.0) with a decision tree in ARCHITECTURE.md; `vendorScoped`/`orgScoped` admin bypass is now role-OR-capability (`vendor:manage`/`org:manage`, configurable) — additive and default-safe, with one deliberate widening: super-admin passes the bypass via the capability engine's implicit grant even under a narrowed custom `adminRoles` (20-test truth table ships with it). LMS v1 sunset: the entire `core/lms` surface (37 exports) carries dated `@deprecated` tags naming each v2 replacement — including two honest no-replacement-yet blockers (Module's three-level shape; the typed lesson Block schemas pending LMS sub-spec 2) — and the new `assert:no-core-lms` script is the removal gate (report-only until 2.0; `--strict` flips it).

  • 6bc419c: Permissions convergence: capabilities (`can`/`canAsync`) are THE runtime-gate API — six flat/hierarchy-unaware checkers (`checkRole`, rbac's `checkPermission`/`checkRoleAsync`/`checkPermissionAsync`/`checkAnyPermission`/`checkAllPermissions`) are `@deprecated` (sunset core 2.0) with a decision tree in ARCHITECTURE.md; `vendorScoped`/`orgScoped` admin bypass is now role-OR-capability (`vendor:manage`/`org:manage`, configurable) — additive and default-safe, with one deliberate widening: super-admin passes the bypass via the capability engine's implicit grant even under a narrowed custom `adminRoles` (20-test truth table ships with it). LMS v1 sunset: the entire `core/lms` surface (37 exports) carries dated `@deprecated` tags naming each v2 replacement — including two honest no-replacement-yet blockers (Module's three-level shape; the typed lesson Block schemas pending LMS sub-spec 2) — and the new `assert:no-core-lms` script is the removal gate (report-only until 2.0; `--strict` flips it).
  • 36e537a: New `timingSafeEqual` utility (exported at `./utilities/timingSafeEqual`): constant-time string comparison that hashes both operands to fixed-length SHA-256 digests before `node:crypto.timingSafeEqual`, so neither content nor length differences leak timing. Also anchors `productTypeHookRegistry` on `globalThis` (Symbol.for) so product-type hook registrations survive Next.js' split RSC/SSR/client module graphs — same fix, same rationale as blocks-core's render registry.
  • 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.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v1.3.4patch

66f394b: Fix a duplicate-key race in the onInit seeders (`initializeRoles` in `auth/initRoles.ts`, `seedLegalPages` in `gdpr/seed/legalPages.ts`) on a fresh/empty database under concurrency — e.g. a Next.js build's "collecting page data" phase, which spawns many parallel worker processes each triggering Payload init against the same DB. **Root cause**: both seeders do a non-atomic check-then-create (`find` → `create`) per document. Two workers can both see a document missing and both call `create`; the loser's duplicate-key rejection wasn't handled correctly. `initializeRoles`'s catch matched on raw driver text (`'duplicate'` / `'E11000'`), but Payload's DB adapters (mongo, postgres, sqlite) all normalize native unique-constraint violations into a `payload` `ValidationError` (`{ data: { errors: [{ path, message }] } }`) before the error reaches consumer code — the raw Mongo `E11000` string never arrives, so the loser's error was rethrown, crashing that worker's `onInit`. `seedLegalPages` had the opposite defect: it blanket-caught **all** errors and continued, so a lost race (or any real failure) silently skipped seeding that page's remaining locales. **Fix**: detect the conflict via Payload's canonical, adapter-agnostic `ValidationError` shape (`instanceof ValidationError` + `data.errors[].path` matching the unique field the seeder keys on) instead of grepping driver-specific error text. On a detected conflict, re-fetch by the key to confirm the document now exists before treating it as success — if the re-fetch comes back empty, the conflict signal was a false positive and the original error is rethrown rather than silently swallowed. - `initializeRoles`: a confirmed race-loss logs the existing `○ Role already exists` line and continues. Sequential/single-worker behavior (log lines, created roles, return type) is unchanged. - `seedLegalPages`: a confirmed race-loss now **continues the per-locale loop against the winner's document** (winner and loser write identical data, so the overlap is idempotent) — a lost race can no longer leave a page missing its non-primary locales. **Behavior change**: non-unique create failures (and locale-update failures) are now rethrown instead of being logged and swallowed — a broken seed now fails loudly instead of half-seeding. **Uniqueness ground truth**: `name` and `slug` on the `roles` collection (`auth/collections/Roles.ts`) both carry `unique: true`, so a real DB-level constraint exists on every supported adapter — the roles failure mode was a crash, not silent duplicate rows; no schema change needed. For `seedLegalPages` the target Pages collection is **consumer-owned**: when the consumer's `slug` field is unique the race surfaces as the handled `ValidationError`; when it isn't, the race silently duplicates pages instead and no seeder-side catch can fire (documented on the helper). The same fix is applied to `@wabbit/tome-admin`'s `seedTomeAdminLayouts` in its own changeset. An atomic `payload.db.upsert()` was considered and rejected for all of these: it bypasses the collection's `hooks`/`access` pipeline that `payload.create()` runs, which would change sequential-case behavior.

  • 66f394b: Fix a duplicate-key race in the onInit seeders (`initializeRoles` in `auth/initRoles.ts`, `seedLegalPages` in `gdpr/seed/legalPages.ts`) on a fresh/empty database under concurrency — e.g. a Next.js build's "collecting page data" phase, which spawns many parallel worker processes each triggering Payload init against the same DB. **Root cause**: both seeders do a non-atomic check-then-create (`find` → `create`) per document. Two workers can both see a document missing and both call `create`; the loser's duplicate-key rejection wasn't handled correctly. `initializeRoles`'s catch matched on raw driver text (`'duplicate'` / `'E11000'`), but Payload's DB adapters (mongo, postgres, sqlite) all normalize native unique-constraint violations into a `payload` `ValidationError` (`{ data: { errors: [{ path, message }] } }`) before the error reaches consumer code — the raw Mongo `E11000` string never arrives, so the loser's error was rethrown, crashing that worker's `onInit`. `seedLegalPages` had the opposite defect: it blanket-caught **all** errors and continued, so a lost race (or any real failure) silently skipped seeding that page's remaining locales. **Fix**: detect the conflict via Payload's canonical, adapter-agnostic `ValidationError` shape (`instanceof ValidationError` + `data.errors[].path` matching the unique field the seeder keys on) instead of grepping driver-specific error text. On a detected conflict, re-fetch by the key to confirm the document now exists before treating it as success — if the re-fetch comes back empty, the conflict signal was a false positive and the original error is rethrown rather than silently swallowed. - `initializeRoles`: a confirmed race-loss logs the existing `○ Role already exists` line and continues. Sequential/single-worker behavior (log lines, created roles, return type) is unchanged. - `seedLegalPages`: a confirmed race-loss now **continues the per-locale loop against the winner's document** (winner and loser write identical data, so the overlap is idempotent) — a lost race can no longer leave a page missing its non-primary locales. **Behavior change**: non-unique create failures (and locale-update failures) are now rethrown instead of being logged and swallowed — a broken seed now fails loudly instead of half-seeding. **Uniqueness ground truth**: `name` and `slug` on the `roles` collection (`auth/collections/Roles.ts`) both carry `unique: true`, so a real DB-level constraint exists on every supported adapter — the roles failure mode was a crash, not silent duplicate rows; no schema change needed. For `seedLegalPages` the target Pages collection is **consumer-owned**: when the consumer's `slug` field is unique the race surfaces as the handled `ValidationError`; when it isn't, the race silently duplicates pages instead and no seeder-side catch can fire (documented on the helper). The same fix is applied to `@wabbit/tome-admin`'s `seedTomeAdminLayouts` in its own changeset. An atomic `payload.db.upsert()` was considered and rejected for all of these: it bypasses the collection's `hooks`/`access` pipeline that `payload.create()` runs, which would change sequential-case behavior.
v1.3.3patch

Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

  • Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
v1.2.1patch

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.
  • 850d51c: Fix `assignment-uploads` upload collection rejecting every file. It set `mimeTypes: ['*/*']`, but Payload's `validateMimeType` strips only the first `*` (`'*/*'` → `'/*'`), so the wildcard matched no detected MIME type and the upload guard blocked all student file submissions. Removed the broken config — omitting `mimeTypes` is the correct "accept any file" setting, and Payload still blocks dangerous executable types via its built-in `checkFileRestrictions` allowlist.
v1.2.0minor

1a5e085: auth: converge the platform permission engine. Adds the `./auth/permissions` sub-module — super-permission hierarchy (`MANAGE_X` implies `EDIT_X`/`DELETE_X`), per-member permission overrides with a constrained (fail-closed) overridable allowlist, sync/async/batched effective-permission checkers, fail-secure resolution on unpopulated roles, a request-scoped authority cache, and a registration seam (`registerSuperPermissions`/`registerOverridablePermissions`) for layer-specific permission key-sets. Fully additive — the existing `Roles` collection, `PERMISSIONS` catalog, `rbac` checkers, and `capabilities` engine are unchanged. This is now the single platform permission engine that `tome-org` and `tome-accounts` build on.

  • 1a5e085: auth: converge the platform permission engine. Adds the `./auth/permissions` sub-module — super-permission hierarchy (`MANAGE_X` implies `EDIT_X`/`DELETE_X`), per-member permission overrides with a constrained (fail-closed) overridable allowlist, sync/async/batched effective-permission checkers, fail-secure resolution on unpopulated roles, a request-scoped authority cache, and a registration seam (`registerSuperPermissions`/`registerOverridablePermissions`) for layer-specific permission key-sets. Fully additive — the existing `Roles` collection, `PERMISSIONS` catalog, `rbac` checkers, and `capabilities` engine are unchanged. This is now the single platform permission engine that `tome-org` and `tome-accounts` build on.
v1.1.0minor

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • baf401e: Removed two phantom export subpaths: `./lms/components/quiz-renderer` and `./lms/components/assignment-renderer`. Their targets (`dist/lms/components/QuizRenderer.*` / `AssignmentRenderer.*`) have **never existed** — `src/lms/components/` is absent from the package, the files are missing from every published tarball (verified against the registry), and no consumer imports the subpaths (verified across all four consumer repos). Leftover keys from before the LMS renderers moved to `@wabbit/tome-lms`. Caught by the new `assert-exports-map` + `smoke-registry-contract` checks on their first run.
v1.0.12patch

8947ff1: Three additive packaging fixes surfaced by a registry consumer's migration off path-aliasing (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible.

  • 8947ff1: Three additive packaging fixes surfaced by a registry consumer's migration off path-aliasing (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible.
v1.0.11patch

36dc023: Align the BetterAuth dependency contract with what the auth layer's source already requires (post the 1.5/1.6 `apiKey` rename). `betterAuthFactory.ts` imports `twoFactor`/`customSession`/`organization` from `better-auth/plugins` + `passkey` from `@better-auth/passkey` and no longer uses `apiKey` (extracted to `@better-auth/api-key` in better-auth 1.6 / dropped from payload-better-auth 0.7). But the package's `peerDependencies` floors were still `better-auth >=1.0.0` / `@delmaredigital/payload-better-auth >=0.3.0`, so a consumer on a stale version installed cleanly and only failed at runtime with a cryptic `does not provide an export named 'apiKey'`. - **peerDependencies** floors raised: `better-auth >=1.6.0`, `@better-auth/passkey >=1.6.0`, `@delmaredigital/payload-better-auth >=0.7.0` — drift now fails loud at install, not at runtime. - **devDependencies** bumped to match (`better-auth ^1.6.11`, `@better-auth/passkey ^1.6.11`, `@delmaredigital/payload-better-auth ^0.7.3`) so the package's own build/tests exercise the real target versions. No source change — type-only / contract-only.

  • 36dc023: Align the BetterAuth dependency contract with what the auth layer's source already requires (post the 1.5/1.6 `apiKey` rename). `betterAuthFactory.ts` imports `twoFactor`/`customSession`/`organization` from `better-auth/plugins` + `passkey` from `@better-auth/passkey` and no longer uses `apiKey` (extracted to `@better-auth/api-key` in better-auth 1.6 / dropped from payload-better-auth 0.7). But the package's `peerDependencies` floors were still `better-auth >=1.0.0` / `@delmaredigital/payload-better-auth >=0.3.0`, so a consumer on a stale version installed cleanly and only failed at runtime with a cryptic `does not provide an export named 'apiKey'`. - **peerDependencies** floors raised: `better-auth >=1.6.0`, `@better-auth/passkey >=1.6.0`, `@delmaredigital/payload-better-auth >=0.7.0` — drift now fails loud at install, not at runtime. - **devDependencies** bumped to match (`better-auth ^1.6.11`, `@better-auth/passkey ^1.6.11`, `@delmaredigital/payload-better-auth ^0.7.3`) so the package's own build/tests exercise the real target versions. No source change — type-only / contract-only.
  • 2612799: `safeRevalidateTag` / `safeRevalidateTags` (`@wabbit/tome-core/data/cacheTags`) now lazily load `next/cache` via dynamic `import()` instead of `require()`. tome-core is `"type": "module"` and ships a dual tsup build with `bundle: false`. A bare `require('next/cache')` in source was preserved verbatim in the emitted ESM `dist/data/cacheTags.js`, where `require` is undefined — true-ESM consumers hit `require is not defined` at first revalidation (this broke wabbit-site-core's admin when source-linked). Dynamic `import()` is preserved verbatim by tsup in both the `.js` and `.cjs` outputs and is natively supported by Node under CommonJS, so it is the module-system-agnostic idiom — the same pattern `@wabbit/tome-core/infra/posthog` already uses to lazily pull an optional peer dep without bundler contamination. Behavior is otherwise identical: out-of-request-scope revalidation is still swallowed via `IGNORABLE_PATTERNS`, and a missing `next/cache` (standalone scripts, non-Next Payload hook cascades) now flows through the same ignorable path instead of throwing. Signature ripple: both functions return `Promise<void>` instead of `void` (dynamic `import()` is async). Every known call site invokes them fire-and-forget inside Payload `afterChange` hooks / server actions and discards the return value, so this is non-breaking in practice. Consumers that want to observe revalidation completion may now `await` them.
v1.0.1patch

**Security: multi-tenant access hardening (CRITICAL).** Closes 2 cross-tenant write bugs and 5 hardening findings from the 2026-04-27 `/autoresearch:security` audit deferred at the 1.0.0 cut. No API changes. **`packages/core/src/access/orgScoped.ts`:** - Added `create:` access guard requiring authenticated user with current `activeOrganizationId` AND non-null `orgRole` (defense in depth alongside the hook fix). - `beforeChange` create hook now **force-overwrites** `orgField` to `user.activeOrganizationId` for non-admins regardless of submitted data. Previously the hook only assigned when the field was empty (`if (!data[orgField])`), letting an attacker plant rows in foreign orgs by submitting `data.organization = '<foreign-org-id>'` (finding 8.2). - Read/update/delete access now re-verifies current org membership via `u.orgRole !== null`, not just `activeOrganizationId` presence (finding 8.1). Stale active-org pointers (e.g. user removed from org since last login) no longer grant access. **`packages/core/src/access/vendorScoped.ts`:** - Added `read:` access guard mirroring `update`/`delete` — read was previously fully unrestricted across vendors despite the wrapper's "restricts CRUD" comment (finding 9.1). - Added `create:` access guard requiring authenticated user. - `beforeChange` create hook now **force-overwrites** `vendorField` to `user.id` for non-admins regardless of submitted data (finding 9.2). **`packages/core/src/auth/jobs/cleanupExpiredSessions.ts` + `cleanupUnverifiedAccounts.ts`:** - Replaced `authHeader !== \`Bearer ${cronSecret}\``plain-string compare with`crypto.timingSafeEqual`and added a`payload.logger.warn`on auth failure (finding 5.1). Plain`===` short-circuits on first byte mismatch and leaks prefix length under sufficient signal-to-noise ratio. **`packages/core/src/auth/betterAuthFactory.ts`:** - `localhost:3000` (http + https) origins are now gated behind `process.env.NODE_ENV !== 'production'` in the BetterAuth `trustedOrigins` array (finding 1.1). Pass explicit prod origins via `opts.trustedOrigins`. **`packages/core/src/access/checkRole.ts`:** - Added `@deprecated` JSDoc directing new code to `@wabbit/tome-core/auth/rbac` (finding 7.1). The function remains exported for backwards compatibility and is consumed internally by `orgScoped`/`vendorScoped` against the customSession-enriched flat role slug array — that consumption is intentional per finding 8.4 (a comment in each wrapper explains the rationale; full migration to async `auth/rbac` paths would impose a DB round-trip on every CRUD access check). No source changes outside `packages/core/src/{access,auth}`. Public API surface, exports map, and types unchanged.

  • **Security: multi-tenant access hardening (CRITICAL).** Closes 2 cross-tenant write bugs and 5 hardening findings from the 2026-04-27 `/autoresearch:security` audit deferred at the 1.0.0 cut. No API changes. **`packages/core/src/access/orgScoped.ts`:** - Added `create:` access guard requiring authenticated user with current `activeOrganizationId` AND non-null `orgRole` (defense in depth alongside the hook fix). - `beforeChange` create hook now **force-overwrites** `orgField` to `user.activeOrganizationId` for non-admins regardless of submitted data. Previously the hook only assigned when the field was empty (`if (!data[orgField])`), letting an attacker plant rows in foreign orgs by submitting `data.organization = '<foreign-org-id>'` (finding 8.2). - Read/update/delete access now re-verifies current org membership via `u.orgRole !== null`, not just `activeOrganizationId` presence (finding 8.1). Stale active-org pointers (e.g. user removed from org since last login) no longer grant access. **`packages/core/src/access/vendorScoped.ts`:** - Added `read:` access guard mirroring `update`/`delete` — read was previously fully unrestricted across vendors despite the wrapper's "restricts CRUD" comment (finding 9.1). - Added `create:` access guard requiring authenticated user. - `beforeChange` create hook now **force-overwrites** `vendorField` to `user.id` for non-admins regardless of submitted data (finding 9.2). **`packages/core/src/auth/jobs/cleanupExpiredSessions.ts` + `cleanupUnverifiedAccounts.ts`:** - Replaced `authHeader !== \`Bearer ${cronSecret}\``plain-string compare with`crypto.timingSafeEqual`and added a`payload.logger.warn`on auth failure (finding 5.1). Plain`===` short-circuits on first byte mismatch and leaks prefix length under sufficient signal-to-noise ratio. **`packages/core/src/auth/betterAuthFactory.ts`:** - `localhost:3000` (http + https) origins are now gated behind `process.env.NODE_ENV !== 'production'` in the BetterAuth `trustedOrigins` array (finding 1.1). Pass explicit prod origins via `opts.trustedOrigins`. **`packages/core/src/access/checkRole.ts`:** - Added `@deprecated` JSDoc directing new code to `@wabbit/tome-core/auth/rbac` (finding 7.1). The function remains exported for backwards compatibility and is consumed internally by `orgScoped`/`vendorScoped` against the customSession-enriched flat role slug array — that consumption is intentional per finding 8.4 (a comment in each wrapper explains the rationale; full migration to async `auth/rbac` paths would impose a DB round-trip on every CRUD access check). No source changes outside `packages/core/src/{access,auth}`. Public API surface, exports map, and types unchanged.
v1.0.0major

**Graduate `@wabbit/tome-core` to 1.x** — version-policy change, no API change. Diagnosed root cause of the 2026-04-27 `pnpm changeset version` cascade: while tome-core sits at 0.x, `^0.4.0` peer ranges (resolved from `workspace:^` at publish) do NOT satisfy `0.5.0` per semver-zero rules, so `@changesets/assemble-release-plan` correctly force-major-bumps every peer-dependent on every minor release. Only fix is moving tome-core out of 0.x. After this release, `^1.x.0` peer ranges accept future minor bumps cleanly and the cascade disappears. Existing published peer-dependents (`@wabbit/tome-admin@0.4.1`, `@wabbit/tome-economy@0.2.0`, `@wabbit/tome-catalog@1.1.0`, `@wabbit/tome-blocks-core@0.3.0`) ship with peerDeps frozen at the 0.5.x range and will produce installation peer-dep warnings against `tome-core@1.0.0` until each republishes — warnings only, runtime works. They re-resolve their peer ranges to `^1.0.0` on their next publish naturally. Bumped manually rather than via `pnpm changeset version` to avoid retriggering the same cascade in the changesets run that documents this change. See memory `feedback_changesets_0x_major_bump_bug.md` for the diagnosis and reproduction.

  • **Graduate `@wabbit/tome-core` to 1.x** — version-policy change, no API change. Diagnosed root cause of the 2026-04-27 `pnpm changeset version` cascade: while tome-core sits at 0.x, `^0.4.0` peer ranges (resolved from `workspace:^` at publish) do NOT satisfy `0.5.0` per semver-zero rules, so `@changesets/assemble-release-plan` correctly force-major-bumps every peer-dependent on every minor release. Only fix is moving tome-core out of 0.x. After this release, `^1.x.0` peer ranges accept future minor bumps cleanly and the cascade disappears. Existing published peer-dependents (`@wabbit/tome-admin@0.4.1`, `@wabbit/tome-economy@0.2.0`, `@wabbit/tome-catalog@1.1.0`, `@wabbit/tome-blocks-core@0.3.0`) ship with peerDeps frozen at the 0.5.x range and will produce installation peer-dep warnings against `tome-core@1.0.0` until each republishes — warnings only, runtime works. They re-resolve their peer ranges to `^1.0.0` on their next publish naturally. Bumped manually rather than via `pnpm changeset version` to avoid retriggering the same cascade in the changesets run that documents this change. See memory `feedback_changesets_0x_major_bump_bug.md` for the diagnosis and reproduction.
v0.2.0minor

Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

  • Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

Local

v0.7.0
v0.7.0minor

f321f0b: A class row can now carry a price. The new `price` group holds a type (one price, from a price, a range, free, or price on request), the amounts in whole minor units, and an optional "per" text such as "per person". A schedule gets a `currency` field (an ISO 4217 code, USD when empty). An empty price type shows nothing, and "Free" is only ever something an editor chooses. `normalizeLocalClassSchedule` keeps the price and drops amounts a type doesn't use, and a type missing its amounts becomes no price rather than zero. New exports `validateClassPrices`, `validateMinorUnits`, `validateCurrency` and `LOCAL_CLASS_PRICE_MODES`, and the types `LocalClassPrice` and `LocalClassPriceMode`. The shared readers pass the price through on each occurrence. Rows without a price behave exactly as before.

  • f321f0b: A class row can now carry a price. The new `price` group holds a type (one price, from a price, a range, free, or price on request), the amounts in whole minor units, and an optional "per" text such as "per person". A schedule gets a `currency` field (an ISO 4217 code, USD when empty). An empty price type shows nothing, and "Free" is only ever something an editor chooses. `normalizeLocalClassSchedule` keeps the price and drops amounts a type doesn't use, and a type missing its amounts becomes no price rather than zero. New exports `validateClassPrices`, `validateMinorUnits`, `validateCurrency` and `LOCAL_CLASS_PRICE_MODES`, and the types `LocalClassPrice` and `LocalClassPriceMode`. The shared readers pass the price through on each occurrence. Rows without a price behave exactly as before.
v0.6.0minor

e2f64c4: A save now moves renamed keys to their American names and empties the old ones. This is the second of three steps toward American spelling in stored values. On the class schedule block and collection, `enrolLabel`, `enrolHref`, `enrol` and `labels.enrol` move to `enrollLabel`, `enrollHref`, `enroll` and `labels.enroll`. On the event list board, `labels.cancelled` moves to `labels.canceled`. Anything that still writes an old key lands in the new one. This fixes clearing a field: a value sent for the new key, even a blank one, wins and the old value is emptied, so it no longer comes back on the next read or shows on the public page. The old group and label no longer have default values, so a new block stores nothing under them. Renderers still read the old keys, so a block that hasn't been saved or backfilled looks the same. The class schedule and event calendar demos use the new names. **BREAKING:** `@wabbit/tome-blocks-local-pack` now needs `@wabbit/tome-local` 0.6.0 or later, which exports these save hooks from `@wabbit/tome-local/classes`.

  • e2f64c4: A save now moves renamed keys to their American names and empties the old ones. This is the second of three steps toward American spelling in stored values. On the class schedule block and collection, `enrolLabel`, `enrolHref`, `enrol` and `labels.enrol` move to `enrollLabel`, `enrollHref`, `enroll` and `labels.enroll`. On the event list board, `labels.cancelled` moves to `labels.canceled`. Anything that still writes an old key lands in the new one. This fixes clearing a field: a value sent for the new key, even a blank one, wins and the old value is emptied, so it no longer comes back on the next read or shows on the public page. The old group and label no longer have default values, so a new block stores nothing under them. Renderers still read the old keys, so a block that hasn't been saved or backfilled looks the same. The class schedule and event calendar demos use the new names. **BREAKING:** `@wabbit/tome-blocks-local-pack` now needs `@wabbit/tome-local` 0.6.0 or later, which exports these save hooks from `@wabbit/tome-local/classes`.
v0.5.2patch

3606dc7: The `enrol` names and two option values now accept their American spellings. This is the first of three steps, and nothing that gets written changes yet. Class schedule rows and the class schedule block gain `enrollLabel`, `enrollHref`, the `enroll` link group and the `labels.enroll` copy key. The renderer reads each new key first and falls back to the old `enrol` key, field by field. The old fields are hidden in the admin, not removed, so a save never drops a stored value, and the new fields show the stored value while the new key is empty. The credential kind `license` joins `licence`, and the price list tag `house-favorite` joins `house-favourite`. A credentials strip claim bound to either spelling of the kind matches both, and both tag values show the same label. The admin shows both options, because Payload cannot hide one select option; the old one is labeled "(legacy spelling)".

  • 3606dc7: The `enrol` names and two option values now accept their American spellings. This is the first of three steps, and nothing that gets written changes yet. Class schedule rows and the class schedule block gain `enrollLabel`, `enrollHref`, the `enroll` link group and the `labels.enroll` copy key. The renderer reads each new key first and falls back to the old `enrol` key, field by field. The old fields are hidden in the admin, not removed, so a save never drops a stored value, and the new fields show the stored value while the new key is empty. The credential kind `license` joins `licence`, and the price list tag `house-favorite` joins `house-favourite`. A credentials strip claim bound to either spelling of the kind matches both, and both tag values show the same label. The admin shows both options, because Payload cannot hide one select option; the old one is labeled "(legacy spelling)".
v0.5.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.5.0minor

13ea0f3: New opt-in `local-testimonials` collection, which shows visitors only quotes with consent recorded. Neutral quotes (`quote`, `name`, `role`, `company`, `image`, `publishedAt`, `order`, `complianceLabel`) with a `consent` group (`given`, and the date it was given). The `testimonial` block reads it through its `testimonialsCollection` option. Turn it on with `createLocalLayer({ testimonials: true })`; off (the default), nothing changes: no collection, no capability, and `slugs` carries no `testimonials` key, held by a test. Visitors read only published quotes with consent recorded (a where-constraint, so a draft or an unconsented quote never leaves the server over REST or GraphQL); editors need `local:edit-testimonials`, granted to the layer's staff roles. Drafts are on, `dbName` defaults to `local_tst`. SQL sites that turn it on need a migration for the new tables; Mongo sites need none.

  • 13ea0f3: New opt-in `local-testimonials` collection, which shows visitors only quotes with consent recorded. Neutral quotes (`quote`, `name`, `role`, `company`, `image`, `publishedAt`, `order`, `complianceLabel`) with a `consent` group (`given`, and the date it was given). The `testimonial` block reads it through its `testimonialsCollection` option. Turn it on with `createLocalLayer({ testimonials: true })`; off (the default), nothing changes: no collection, no capability, and `slugs` carries no `testimonials` key, held by a test. Visitors read only published quotes with consent recorded (a where-constraint, so a draft or an unconsented quote never leaves the server over REST or GraphQL); editors need `local:edit-testimonials`, granted to the layer's staff roles. Drafts are on, `dbName` defaults to `local_tst`. SQL sites that turn it on need a migration for the new tables; Mongo sites need none.
v0.4.0minor

0517734: New opt-in `local-locations` collection: one record per place, each with its own address, phone, hours and status, while the business profile stays the identity record. Turn it on with `createLocalLayer({ locations: true })`; off (the default), nothing changes, and a test holds the profile's schema against a snapshot taken before the change. The place-shaped fields are the profile's own builders, now shared with the collection without changing the profile's field order, names or types. Reads from `@wabbit/tome-local/server`: `getLocalLocations`, `getLocalLocation`, `getLocationsStatus` (every place's status, each in its own timezone) and `resolvePrimaryHoursSource` (the primary location's hours, else the profile's); `getLiveStatus` takes an optional `location`. New `@wabbit/tome-local/locations` entry: `locationHoursSource`, `locationPins` (no pin for a place whose address is hidden), `locationJsonLdInput` (with `parentOrganization` when there is more than one place) and uncached readers. At most one location is `primary`; a published, unarchived place is all a visitor can read; a place a record or block still points at is archived, not deleted. The profile gains an optional `urgentNotice` (wording and up to two labeled numbers). It ships empty with no default wording, an empty one renders nothing and projects no key, and it works with or without locations.

  • 0517734: New opt-in `local-locations` collection: one record per place, each with its own address, phone, hours and status, while the business profile stays the identity record. Turn it on with `createLocalLayer({ locations: true })`; off (the default), nothing changes, and a test holds the profile's schema against a snapshot taken before the change. The place-shaped fields are the profile's own builders, now shared with the collection without changing the profile's field order, names or types. Reads from `@wabbit/tome-local/server`: `getLocalLocations`, `getLocalLocation`, `getLocationsStatus` (every place's status, each in its own timezone) and `resolvePrimaryHoursSource` (the primary location's hours, else the profile's); `getLiveStatus` takes an optional `location`. New `@wabbit/tome-local/locations` entry: `locationHoursSource`, `locationPins` (no pin for a place whose address is hidden), `locationJsonLdInput` (with `parentOrganization` when there is more than one place) and uncached readers. At most one location is `primary`; a published, unarchived place is all a visitor can read; a place a record or block still points at is archived, not deleted. The profile gains an optional `urgentNotice` (wording and up to two labeled numbers). It ships empty with no default wording, an empty one renders nothing and projects no key, and it works with or without locations.
v0.3.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.0minor

a02daa8: Add an opt-in shared class timetable: the `local-class-schedules` collection and a `./classes` entry for the session arithmetic. `createLocalLayer({ classSchedules: true })` builds one timetable per site or location (amendment A3): classes, term, breaks, timezone, age bands (`ageBands[]`) and a level ladder (`levels[]`, where order within a track is the step). Drafts are on, the public reads published documents only, and writes need the new `local:edit-schedules` capability, granted to the same roles as the profile capability. `createLocalLayer` now also returns `collections` (empty unless the option is on), and `LocalSlugs` gains `classSchedules`. The new isomorphic `@wabbit/tome-local/classes` exports `sessionsThisWeek`, `nextSessions`, `levelPosition`, `activeSpots`, `inAgeBand` and the schedule types. `getLocalClassSchedule` in `./server` reads the cached document. A spots line on an occurrence speaks only for sessions that start before it expires. `ageBands[]` carries three optional wording fields, `pillLabel`, `selectLabel` and `saidLabel` (the finder's pill, the timetable's filter option and the finder's said line), each falling back to `label` when empty; the normalizer and the `./classes` `LocalAgeBand` type carry them. Optional with no defaults: existing documents are unchanged, and the new fields add columns to the collection's tables, so a site with migrations generates one. Profile credentials gain an optional `sample` checkbox, projected so a block can label a demo credential "Sample". Purely additive: nothing changes for a site that does not turn the option on, and no peer is added or tightened.

  • a02daa8: Add an opt-in shared class timetable: the `local-class-schedules` collection and a `./classes` entry for the session arithmetic. `createLocalLayer({ classSchedules: true })` builds one timetable per site or location (amendment A3): classes, term, breaks, timezone, age bands (`ageBands[]`) and a level ladder (`levels[]`, where order within a track is the step). Drafts are on, the public reads published documents only, and writes need the new `local:edit-schedules` capability, granted to the same roles as the profile capability. `createLocalLayer` now also returns `collections` (empty unless the option is on), and `LocalSlugs` gains `classSchedules`. The new isomorphic `@wabbit/tome-local/classes` exports `sessionsThisWeek`, `nextSessions`, `levelPosition`, `activeSpots`, `inAgeBand` and the schedule types. `getLocalClassSchedule` in `./server` reads the cached document. A spots line on an occurrence speaks only for sessions that start before it expires. `ageBands[]` carries three optional wording fields, `pillLabel`, `selectLabel` and `saidLabel` (the finder's pill, the timetable's filter option and the finder's said line), each falling back to `label` when empty; the normalizer and the `./classes` `LocalAgeBand` type carry them. Optional with no defaults: existing documents are unchanged, and the new fields add columns to the collection's tables, so a site with migrations generates one. Profile credentials gain an optional `sample` checkbox, projected so a block can label a demo credential "Sample". Purely additive: nothing changes for a site that does not turn the option on, and no peer is added or tightened.
  • ffca5dc: A level in the shared class timetable can carry a teaser sentence, a one-line card blurb and a short age tag. Three optional text fields on `levels[]` in the `local-class-schedules` collection, all empty by default: - `teaser`: one or two sentences a class pathway shows when the level is the next one up. Empty falls back to `forWho`. - `blurb`: one line shown on the level's card in place of "Who it's for" and "Moves up when". Empty keeps the pair. - `ageTag`: a short tag beside the level's name, such as "Walking–3". The normalizer and the `./classes` `LocalLevel` type carry them. Purely additive: existing documents are unchanged, and the new fields add columns to the collection's level table, so a site with migrations generates one. No peer is added or tightened.
v0.2.1patch

4a1728c: The business-profile reader now uses tome-core's shared `loadUnstableCache()` instead of its own copy. The `@wabbit/tome-core` peer floor rises to 1.23.0, the release that adds it. No behavior change.

  • 4a1728c: The business-profile reader now uses tome-core's shared `loadUnstableCache()` instead of its own copy. The `@wabbit/tome-core` peer floor rises to 1.23.0, the release that adds it. No behavior change.
v0.2.0minor

3c51ebc: New: the site's business profile, a Payload global that editors fill in once in the admin and that blocks and structured data read. It holds the name, contact details, address, timezone, hours (split shifts, holidays and named schedules), service areas, credentials, an expiring availability status, a response promise and a rating citation, and is read through a cached public projection. **BREAKING:** `@wabbit/tome-core` (`>=1.17.0 <2.0.0`) and `payload` (`>=3.67.0`) are now required peers, because the root entry is Payload config. `next` (`>=15.0.0`) is an optional peer. Sites that import only `./hours` or `./jsonld` already have both in any Tome install; nothing else changes for them. - **`createLocalLayer(config)`** returns `{ globals, plugin, slugs }` and registers the `local-business-profile` global (admin group "Site", no drafts, so edits go live on save). It grants the `local:edit-profile` capability to `admin` and `editor` (`grantRoles` changes that). Options: `schemaTypes`, `credentialKinds`, `updateCapability`, `mediaSlug`, `dbName`, plus `adminGroup`, `slugs`, `access`, `hooks`, `extraFields`, `fieldOverrides`, `omitFields` and `fieldOrder`. - **Validation on save:** the phone is stored in E.164 (spaces and punctuation stripped), the timezone must be an IANA name, weekly hours may not overlap anywhere in the week (overnight windows included), schedule keys are unique, a service-area radius needs a center, and any availability status other than "none" needs an expiry. The server writes each override's `dateKey` and the availability `updatedAt`. - **Privacy:** the global is publicly readable over REST and GraphQL, but a visitor's read never shows a hidden street address or its coordinates, an expired credential, a credential number marked hidden, or an expired availability status. - **`./profile`** (new, isomorphic): `projectLocalBusinessProfile`, `profileHoursSource` (a `LocalHoursSource` that also works with `getOpenState(profile, now, 'kitchen')`), the uncached `readLocalBusinessProfile` for use inside Payload hooks, `findLocalProfileSlug`, and the `LocalBusinessProfilePublic` type. - **`./server`** (new): `getLocalBusinessProfile(payload)`, cached under `LOCAL_PROFILE_TAG` and revalidated when the profile is saved, with expiry rules applied on every read; `getProfileHoursSource` and `getLiveStatus`. - **`./live-status`** (new, isomorphic): `resolveLiveStatus('open-state' | 'availability' | 'auto', ctx)` and the `liveStatusSources` registry. The availability chain runs registered sources by priority, then the built-in manual status, then the rating citation, and returns `null` rather than guess. - **`./jsonld`:** `profileToLocalBusinessInput(profile, { siteUrl })` maps the profile to `buildLocalBusinessJsonLd`'s input. A hidden address emits no street or coordinates, and no rating is ever emitted. - **Field factories:** `localHoursField()` and `localHoursOverridesField()` store the same shape as the directory's hours fields, with an additive `dateKey` column; `localSchedulesField()` adds named schedules.

  • 3c51ebc: New: the site's business profile, a Payload global that editors fill in once in the admin and that blocks and structured data read. It holds the name, contact details, address, timezone, hours (split shifts, holidays and named schedules), service areas, credentials, an expiring availability status, a response promise and a rating citation, and is read through a cached public projection. **BREAKING:** `@wabbit/tome-core` (`>=1.17.0 <2.0.0`) and `payload` (`>=3.67.0`) are now required peers, because the root entry is Payload config. `next` (`>=15.0.0`) is an optional peer. Sites that import only `./hours` or `./jsonld` already have both in any Tome install; nothing else changes for them. - **`createLocalLayer(config)`** returns `{ globals, plugin, slugs }` and registers the `local-business-profile` global (admin group "Site", no drafts, so edits go live on save). It grants the `local:edit-profile` capability to `admin` and `editor` (`grantRoles` changes that). Options: `schemaTypes`, `credentialKinds`, `updateCapability`, `mediaSlug`, `dbName`, plus `adminGroup`, `slugs`, `access`, `hooks`, `extraFields`, `fieldOverrides`, `omitFields` and `fieldOrder`. - **Validation on save:** the phone is stored in E.164 (spaces and punctuation stripped), the timezone must be an IANA name, weekly hours may not overlap anywhere in the week (overnight windows included), schedule keys are unique, a service-area radius needs a center, and any availability status other than "none" needs an expiry. The server writes each override's `dateKey` and the availability `updatedAt`. - **Privacy:** the global is publicly readable over REST and GraphQL, but a visitor's read never shows a hidden street address or its coordinates, an expired credential, a credential number marked hidden, or an expired availability status. - **`./profile`** (new, isomorphic): `projectLocalBusinessProfile`, `profileHoursSource` (a `LocalHoursSource` that also works with `getOpenState(profile, now, 'kitchen')`), the uncached `readLocalBusinessProfile` for use inside Payload hooks, `findLocalProfileSlug`, and the `LocalBusinessProfilePublic` type. - **`./server`** (new): `getLocalBusinessProfile(payload)`, cached under `LOCAL_PROFILE_TAG` and revalidated when the profile is saved, with expiry rules applied on every read; `getProfileHoursSource` and `getLiveStatus`. - **`./live-status`** (new, isomorphic): `resolveLiveStatus('open-state' | 'availability' | 'auto', ctx)` and the `liveStatusSources` registry. The availability chain runs registered sources by priority, then the built-in manual status, then the rating citation, and returns `null` rather than guess. - **`./jsonld`:** `profileToLocalBusinessInput(profile, { siteUrl })` maps the profile to `buildLocalBusinessJsonLd`'s input. A hidden address emits no street or coordinates, and no rating is ever emitted. - **Field factories:** `localHoursField()` and `localHoursOverridesField()` store the same shape as the directory's hours fields, with an additive `dateKey` column; `localSchedulesField()` adds named schedules.
v0.1.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.1.0minor

35b20ca: New package: `@wabbit/tome-local`, a browser-safe open-state utility and a LocalBusiness JSON-LD builder for local-business sites. - **`./hours`:** `computeOpenState` returns `openNow`, `closesInMinutes` and `opensAt`, plus `closesAt`, the `current` and `next` windows, and a `closedLabel` for a closed-today override. It handles several windows per day (split shifts), several override windows per date, overrides stored as a bare `YYYY-MM-DD` or as the ISO instant a database returns for a date-only field, and daylight-saving transitions (a time inside a spring-forward gap moves past the gap; an ambiguous fall-back time takes its first occurrence). Touching windows merge, so a business open through midnight reports its real closing time. Also `getOpenState` (any record with hours, plus named schedules), `listWindows`, `formatOpenStateLabel` ("Open now · closes at 10 PM", "Opens tomorrow at 11 AM", "Closed today · Holiday", with "today" in the business timezone), a wrap-aware `validateWeeklyHours`, the `toLocalDateKey` / `toDateOnlyKey` helpers and the `LocalHoursSource` interface. No `payload`, `server-only`, React or date-library import. - **`./jsonld`:** `buildLocalBusinessJsonLd` builds schema.org LocalBusiness (or a subtype, or Organization) markup from a neutral input: conditional fields only, `openingHoursSpecification` per weekly row with dated overrides inside a horizon, `areaServed` as City / AdministrativeArea / Place / GeoCircle, a hidden-address mode that omits the street line and coordinates, and never `aggregateRating` or `review`. `serializeJsonLd` escapes the output for an inline script tag.

  • 35b20ca: New package: `@wabbit/tome-local`, a browser-safe open-state utility and a LocalBusiness JSON-LD builder for local-business sites. - **`./hours`:** `computeOpenState` returns `openNow`, `closesInMinutes` and `opensAt`, plus `closesAt`, the `current` and `next` windows, and a `closedLabel` for a closed-today override. It handles several windows per day (split shifts), several override windows per date, overrides stored as a bare `YYYY-MM-DD` or as the ISO instant a database returns for a date-only field, and daylight-saving transitions (a time inside a spring-forward gap moves past the gap; an ambiguous fall-back time takes its first occurrence). Touching windows merge, so a business open through midnight reports its real closing time. Also `getOpenState` (any record with hours, plus named schedules), `listWindows`, `formatOpenStateLabel` ("Open now · closes at 10 PM", "Opens tomorrow at 11 AM", "Closed today · Holiday", with "today" in the business timezone), a wrap-aware `validateWeeklyHours`, the `toLocalDateKey` / `toDateOnlyKey` helpers and the `LocalHoursSource` interface. No `payload`, `server-only`, React or date-library import. - **`./jsonld`:** `buildLocalBusinessJsonLd` builds schema.org LocalBusiness (or a subtype, or Organization) markup from a neutral input: conditional fields only, `openingHoursSpecification` per weekly row with dated overrides inside a horizon, `areaServed` as City / AdministrativeArea / Place / GeoCircle, a hidden-address mode that omits the street line and coordinates, and never `aggregateRating` or `review`. `serializeJsonLd` escapes the output for an inline script tag.

Portfolio

v0.4.0
v0.4.0minor

82d384a: `requestMemo` now comes from `@wabbit/tome-core` instead of a local copy; behavior and the `./server` export are unchanged. **BREAKING:** the `@wabbit/tome-core` peer floor rises to `>=1.32.0`.

  • 82d384a: `requestMemo` now comes from `@wabbit/tome-core` instead of a local copy; behavior and the `./server` export are unchanged. **BREAKING:** the `@wabbit/tome-core` peer floor rises to `>=1.32.0`.
v0.3.0minor

78d9bf7: Honors `staff` from `LayerFactoryConfig`: operations this layer left without an access rule go to the site's staff check. This layer passes a hand-built config to `applyLayerFactoryConfig`, so it now forwards `staff` explicitly. Without that, it would accept the option and silently drop it. Omitting `staff` changes nothing.

  • 78d9bf7: Honors `staff` from `LayerFactoryConfig`: operations this layer left without an access rule go to the site's staff check. This layer passes a hand-built config to `applyLayerFactoryConfig`, so it now forwards `staff` explicitly. Without that, it would accept the option and silently drop it. Omitting `staff` changes nothing.
v0.2.0minor

acdab21: A client-safe `@wabbit/tome-portfolio/settings` entry exports the settings defaults, the merge function and the settings types. It imports no payload, no server code and no other Tome package, so a client bundle can read the vocabulary defaults without loading the layer's collections and hooks. The root entry still re-exports the same names.

  • acdab21: A client-safe `@wabbit/tome-portfolio/settings` entry exports the settings defaults, the merge function and the settings types. It imports no payload, no server code and no other Tome package, so a client bundle can read the vocabulary defaults without loading the layer's collections and hooks. The root entry still re-exports the same names.
v0.1.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.1patch

daee7d2: A site with the portfolio layer can serve `/api/graphql` again. The plate `aspect` field was a select whose values (`4:3`, `16:9`, ...) contain a colon. Payload turns each select option into a GraphQL enum name and does not strip `:`, so building the schema threw and every `/api/graphql` request on the site returned 500. `aspect` is now a text field that accepts exactly the same six values (`4:3`, `4:5`, `3:2`, `16:9`, `21:9`, `16:10`, or empty), so nothing stored changes: existing projects read back as before through the local API, REST and now GraphQL (as a `String`), and `PortfolioAspect` is unchanged. The field tidies what an editor types (`4x3`, `4/3`, `4 × 3` are saved as `4:3`) and refuses anything else. In the admin the dropdown becomes a text input with the six values listed under it. On MongoDB nothing needs migrating; a Postgres site's next migration turns the column's enum into `varchar`, which keeps every stored value.

  • daee7d2: A site with the portfolio layer can serve `/api/graphql` again. The plate `aspect` field was a select whose values (`4:3`, `16:9`, ...) contain a colon. Payload turns each select option into a GraphQL enum name and does not strip `:`, so building the schema threw and every `/api/graphql` request on the site returned 500. `aspect` is now a text field that accepts exactly the same six values (`4:3`, `4:5`, `3:2`, `16:9`, `21:9`, `16:10`, or empty), so nothing stored changes: existing projects read back as before through the local API, REST and now GraphQL (as a `String`), and `PortfolioAspect` is unchanged. The field tidies what an editor types (`4x3`, `4/3`, `4 × 3` are saved as `4:3`) and refuses anything else. In the admin the dropdown becomes a text input with the six values listed under it. On MongoDB nothing needs migrating; a Postgres site's next migration turns the column's enum into `varchar`, which keeps every stored value.
v0.1.0minor

a9d759e: New layer: a site's body of work as records. Adds the `portfolio-projects` and `portfolio-types` collections and the `portfolio-settings` global through `createPortfolioLayer`, with permanent job numbers (entered, unique per number series, kept when a project is archived), a story of passages and plates with an image mode per item, structured credits, and inline SVG drawings sanitized on save by an allow-list. Ships a server read API (filtered list with conditional facet counts, featured, next within a filter, computed facts, one project with its numbered story, type options) and an isomorphic `./filters` entry for URL filter state. `createPortfolioLayer({ hrefFor })` replaces the default `${basePath}/${slug}` project address (to keep reserved demo slugs out of URLs or serve several base paths); index entries carry `slug`. - Fix: the layer's reads no longer re-populate a project's `layout` blocks, so a block read hook that calls the read API on the same `req` cannot wait on the read that is waiting for it (a project update or a case-study render used to hang). `getPortfolioProject` omits `layout` unless `withLayout: true` (a route that renders the layout passes it; a block hook never does); its memo key includes it. The project read selects every field except `layout`, and a read asked for again from inside itself logs and returns `null` instead of hanging.

  • a9d759e: New layer: a site's body of work as records. Adds the `portfolio-projects` and `portfolio-types` collections and the `portfolio-settings` global through `createPortfolioLayer`, with permanent job numbers (entered, unique per number series, kept when a project is archived), a story of passages and plates with an image mode per item, structured credits, and inline SVG drawings sanitized on save by an allow-list. Ships a server read API (filtered list with conditional facet counts, featured, next within a filter, computed facts, one project with its numbered story, type options) and an isomorphic `./filters` entry for URL filter state. `createPortfolioLayer({ hrefFor })` replaces the default `${basePath}/${slug}` project address (to keep reserved demo slugs out of URLs or serve several base paths); index entries carry `slug`. - Fix: the layer's reads no longer re-populate a project's `layout` blocks, so a block read hook that calls the read API on the same `req` cannot wait on the read that is waiting for it (a project update or a case-study render used to hang). `getPortfolioProject` omits `layout` unless `withLayout: true` (a route that renders the layout passes it; a block hook never does); its memo key includes it. The project read selects every field except `layout`, and a read asked for again from inside itself logs and returns `null` instead of hanging.

Maps

v0.1.2
v0.1.2patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.1patch

4b831eb: The MapLibre and Mapbox providers now load their own stylesheet, so a map draws correctly without a manual CSS import. Without the provider's stylesheet the canvas was not sized, MapLibre's gesture hints printed as plain text under the map, and the pins were not positioned. Each provider now imports its package's `dist` stylesheet beside the SDK when the map mounts. A bundler serves it as a local file, so nothing is requested from a map host before the visitor presses "Show map", and a guarded map loads neither the SDK nor the sheet.

  • 4b831eb: The MapLibre and Mapbox providers now load their own stylesheet, so a map draws correctly without a manual CSS import. Without the provider's stylesheet the canvas was not sized, MapLibre's gesture hints printed as plain text under the map, and the pins were not positioned. Each provider now imports its package's `dist` stylesheet beside the SDK when the map mounts. A bundler serves it as a local file, so nothing is requested from a map host before the visitor presses "Show map", and a guarded map loads neither the SDK nor the sheet.
  • 8bd0d91: The header status strip now updates itself like the desk strip, and a map that cannot load says so. The multi-office strip in the chrome announcement slot (`PracticeStatusAnnouncement`) is still rendered on the server, but its status line is now a small client island that recomputes from the same hours on mount, every 30 seconds and when the tab comes back, so a cached page can no longer say "Open now" after closing time while the desk strip below it says closed. Its first render equals the server's, so nothing flickers. When the map's tiles or style are blocked, the maps providers print a plain sentence ("The map could not load. The directions links still work.", settable with `failedMessage`) beside the directions links instead of failing silently.
v0.1.0minor

1619d8c: New `@wabbit/tome-maps` package: the map and geocoder seam the directory used, as a free neutral package, with MapLibre, Google and Mapbox providers. Providers load their SDK only when a map mounts, or, with `loadOn: 'interaction'`, only after the visitor presses "Show map". A failed or blocked map renders your `fallback` (for example a directions link). `MapPin` is vertical-neutral with an open `meta` bag. Each provider accepts an optional injected `guard`; none applies by default. The map SDKs stay optional peers.

  • 1619d8c: New `@wabbit/tome-maps` package: the map and geocoder seam the directory used, as a free neutral package, with MapLibre, Google and Mapbox providers. Providers load their SDK only when a map mounts, or, with `loadOn: 'interaction'`, only after the visitor presses "Show map". A failed or blocked map renders your `fallback` (for example a directions link). `MapPin` is vertical-neutral with an open `meta` bag. Each provider accepts an optional injected `guard`; none applies by default. The map SDKs stay optional peers.

Practice

v0.2.0
v0.2.0minor

9fa07bc: Four additive changes; a site that uses none of them sees no difference. `listPracticePeople` takes `includeCredentials` (each entry also carries its current credentials from the same batch, so a roster makes no per-person read) and `group` (only people in that group). People gain an optional `groups` relationship. Offerings gain an optional `review` with the same shape, byline and computed state as a topic's. Settings gain `durationLabel`, default "Duration". The README now says that a `fixed`, `from` or `range` price requires an amount, that a price quoted later is `request`, and that the first listed person is the responsible one by convention.

  • 9fa07bc: Four additive changes; a site that uses none of them sees no difference. `listPracticePeople` takes `includeCredentials` (each entry also carries its current credentials from the same batch, so a roster makes no per-person read) and `group` (only people in that group). People gain an optional `groups` relationship. Offerings gain an optional `review` with the same shape, byline and computed state as a topic's. Settings gain `durationLabel`, default "Duration". The README now says that a `fixed`, `from` or `range` price requires an amount, that a price quoted later is `request`, and that the first listed person is the responsible one by convention.
v0.1.0minor

e8f2958: New `@wabbit/tome-practice` package: who works at a business, what it offers and what people come looking for, as linked records. Free, foundation family. `createPracticeLayer` adds `practice-people`, `practice-offerings`, `practice-topics` and `practice-groups` collections and a `practice-settings` global. The site names things through the settings, so nothing in the package names a trade. Each link is stored once, by its owner (an offering holds its people; a topic holds its offerings and any direct people); the reverse views are read-only joins. Writes need `practice:edit-records`, never bare `authenticated`. The `@wabbit/tome-practice/server` read API (`listPracticePeople`, `getPracticePerson`, `listPracticeOfferings`, `getPracticeOffering`, `listPracticeTopics`, `getPracticeTopic`, `getPracticeGroupIndex`, `getPracticeSettings`) filters to published, unarchived records on both sides of every link, in a fixed number of queries however many rows there are. Review state, expired availability and expired credentials are decided at read time and never stored, so a cached row cannot outlive its expiry. A credential number reaches no public read unless its row sets `showNumber`. The `./filters` entry parses and serializes list filters for server and browser alike. Requires a `@wabbit/tome-core` release that exports `@wabbit/tome-core/utilities/safeHref`. The package ships no migration; the consumer owns it.

  • e8f2958: New `@wabbit/tome-practice` package: who works at a business, what it offers and what people come looking for, as linked records. Free, foundation family. `createPracticeLayer` adds `practice-people`, `practice-offerings`, `practice-topics` and `practice-groups` collections and a `practice-settings` global. The site names things through the settings, so nothing in the package names a trade. Each link is stored once, by its owner (an offering holds its people; a topic holds its offerings and any direct people); the reverse views are read-only joins. Writes need `practice:edit-records`, never bare `authenticated`. The `@wabbit/tome-practice/server` read API (`listPracticePeople`, `getPracticePerson`, `listPracticeOfferings`, `getPracticeOffering`, `listPracticeTopics`, `getPracticeTopic`, `getPracticeGroupIndex`, `getPracticeSettings`) filters to published, unarchived records on both sides of every link, in a fixed number of queries however many rows there are. Review state, expired availability and expired credentials are decided at read time and never stored, so a cached row cannot outlive its expiry. A credential number reaches no public read unless its row sets `showNumber`. The `./filters` entry parses and serializes list filters for server and browser alike. Requires a `@wabbit/tome-core` release that exports `@wabbit/tome-core/utilities/safeHref`. The package ships no migration; the consumer owns it.

Ui

v0.22.3
v0.22.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.22.2patch

76e65cb: Adds the --tome-app-max-width token so a theme can set the learner app width. Declares the `--tome-app-max-width` token (default `none`) beside `--tome-container`, so a theme can set the width of the learner app; nothing changes until it does.

  • 76e65cb: Adds the --tome-app-max-width token so a theme can set the learner app width. Declares the `--tome-app-max-width` token (default `none`) beside `--tome-container`, so a theme can set the width of the learner app; nothing changes until it does.
v0.22.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.22.0minor

9e7b353: `Sheet` now carries `data-sheet-overlay` on its scrim and `data-sheet-content` on its panel, so a theme can restyle them without matching Radix's structure. Both are additive attributes beside the existing `data-side`; no class or markup changes otherwise, and nothing a consumer has today needs to change.

  • 9e7b353: `Sheet` now carries `data-sheet-overlay` on its scrim and `data-sheet-content` on its panel, so a theme can restyle them without matching Radix's structure. Both are additive attributes beside the existing `data-side`; no class or markup changes otherwise, and nothing a consumer has today needs to change.
v0.21.1patch

0a543c5: Base reset no longer strips radio buttons and checkboxes: they keep their native control, so forms that tint them with accent-color render a visible, checkable control instead of an invisible box.

  • 0a543c5: Base reset no longer strips radio buttons and checkboxes: they keep their native control, so forms that tint them with accent-color render a visible, checkable control instead of an invisible box.
v0.21.0minor

a679cc1: A block moved to a narrower column by `--tome-block-column` can now fill that column. The block wrapper default places a block root on `var(--tome-block-inner-column, 2 / -2)`. Unset, it is exactly the `2 / -2` it was. A template or theme that moves one block's wrapper to a narrower span sets `--tome-block-inner-column: 1 / -1` beside it, so the root fills the span instead of losing a column on each side (the Shelf product page's gallery and buy box). Purely additive: no new required argument and no changed default.

  • a679cc1: A block moved to a narrower column by `--tome-block-column` can now fill that column. The block wrapper default places a block root on `var(--tome-block-inner-column, 2 / -2)`. Unset, it is exactly the `2 / -2` it was. A template or theme that moves one block's wrapper to a narrower span sets `--tome-block-inner-column: 1 / -1` beside it, so the root fills the span instead of losing a column on each side (the Shelf product page's gallery and buy box). Purely additive: no new required argument and no changed default.
v0.20.0minor

d69161c: The scroll reveal can wait for web fonts and the window load before arming, switched on by a theme declaring `--tome-reveal-wait: fonts`. With the token set on `<html>` the controller arms nothing until `document.fonts.ready` and `load` have fired (or a 4 second cap passes), so layout shift from late fonts no longer skews the above-the-fold decision. The page stays fully visible while it waits, and reduced motion still arms nothing. Without the token the controller arms immediately, exactly as before. `REVEAL_WAIT_TOKEN` and `REVEAL_WAIT_FONTS` are exported from `@wabbit/tome-ui/reveal`.

  • d69161c: The scroll reveal can wait for web fonts and the window load before arming, switched on by a theme declaring `--tome-reveal-wait: fonts`. With the token set on `<html>` the controller arms nothing until `document.fonts.ready` and `load` have fired (or a 4 second cap passes), so layout shift from late fonts no longer skews the above-the-fold decision. The page stays fully visible while it waits, and reduced motion still arms nothing. Without the token the controller arms immediately, exactly as before. `REVEAL_WAIT_TOKEN` and `REVEAL_WAIT_FONTS` are exported from `@wabbit/tome-ui/reveal`.
v0.19.0minor

f7940ba: The scroll reveal no longer writes to any element's inline style: each element's stagger step is now a `data-tome-reveal-order` attribute, which `reveal.css` turns into `--tome-reveal-order`. - Why: any script write to an inline style makes the browser re-serialise the whole `style` attribute, and WebKit spells shorthands out as longhands. Writing `--tome-reveal-order` into a block wrapper's style turned its `grid-column: 1 / -1` into `grid-column-start: 1; grid-column-end: -1`, so any stylesheet matching that text stopped matching in Safari after scrolling. - The timing is unchanged: steps 0 to 6, the same `--tome-reveal-stagger` delay per step, and an element settles by losing its state and order attributes. - New exports from `@wabbit/tome-ui/reveal`: `REVEAL_ORDER_ATTR` (`data-tome-reveal-order`) and `REVEAL_MAX_ORDER` (6). `REVEAL_ORDER_PROPERTY` stays and still names the custom property `reveal.css` reads.

  • f7940ba: The scroll reveal no longer writes to any element's inline style: each element's stagger step is now a `data-tome-reveal-order` attribute, which `reveal.css` turns into `--tome-reveal-order`. - Why: any script write to an inline style makes the browser re-serialise the whole `style` attribute, and WebKit spells shorthands out as longhands. Writing `--tome-reveal-order` into a block wrapper's style turned its `grid-column: 1 / -1` into `grid-column-start: 1; grid-column-end: -1`, so any stylesheet matching that text stopped matching in Safari after scrolling. - The timing is unchanged: steps 0 to 6, the same `--tome-reveal-stagger` delay per step, and an element settles by losing its state and order attributes. - New exports from `@wabbit/tome-ui/reveal`: `REVEAL_ORDER_ATTR` (`data-tome-reveal-order`) and `REVEAL_MAX_ORDER` (6). `REVEAL_ORDER_PROPERTY` stays and still names the custom property `reveal.css` reads.
v0.18.2patch

e880cc7: Scroll reveal: a block that already sits in view at start now still arms its `data-tome-reveal-item` parts that start below the fold, each observed on its own, so they play (and stagger when they enter together) as they arrive. Before, the controller left the whole in-view block alone, so a tall block that starts above the fold (a menu page's cards) never animated its items. The block root and any item already on screen are still never hidden; the no-JS, reduced-motion, opt-out and self-animating (`data-tome-motion="self"`) guarantees cover the per-item path too.

  • e880cc7: Scroll reveal: a block that already sits in view at start now still arms its `data-tome-reveal-item` parts that start below the fold, each observed on its own, so they play (and stagger when they enter together) as they arrive. Before, the controller left the whole in-view block alone, so a tall block that starts above the fold (a menu page's cards) never animated its items. The block root and any item already on screen are still never hidden; the no-JS, reduced-motion, opt-out and self-animating (`data-tome-motion="self"`) guarantees cover the per-item path too.
v0.18.1patch

76aea2c: Fix: `Button` hover tints no longer stick on touch screens after a tap. Touch browsers keep `:hover` on the last element tapped, so a tapped `Button` (a menu toggle, for example) stayed painted in its hover state until the user tapped elsewhere. The hover rules for every variant now sit inside `@media (hover: hover)`, so they apply only where a real pointer can hover. Mouse and trackpad behavior is unchanged.

  • 76aea2c: Fix: `Button` hover tints no longer stick on touch screens after a tap. Touch browsers keep `:hover` on the last element tapped, so a tapped `Button` (a menu toggle, for example) stayed painted in its hover state until the user tapped elsewhere. The hover rules for every variant now sit inside `@media (hover: hover)`, so they apply only where a real pointer can hover. Mouse and trackpad behavior is unchanged.
v0.18.0minor

1767d0d: New opt-in scroll reveal: mount `TomeReveal` from `@wabbit/tome-ui/reveal` once, and turn it on with `data-tome-reveal="on"` or a theme's `--tome-reveal: on`. Block roots (`[data-block-type]`) rise and fade in once as they enter the viewport, with blocks that enter together staggered. Inner parts marked `data-tome-reveal-item` stagger in after their block. Timing comes from four new tokens with neutral defaults in `tokens.css`: `--tome-reveal-rise` (16px), `--tome-reveal-duration` (500ms), `--tome-reveal-ease` and `--tome-reveal-stagger` (80ms). The styles ship as `@wabbit/tome-ui/reveal.css`, which `@wabbit/tome-ui/base` now imports. Additive: the reveal is off by default, and a page that does not opt in renders exactly as before. When it is on, nothing is hidden without JavaScript, under `prefers-reduced-motion: reduce`, in print, or above the fold when the page loads. Only `opacity` and `transform` change, so there is no layout shift. Blocks that animate themselves are skipped automatically: a motion component marks its own root with `data-tome-motion="self"`, and the reveal never arms a block containing one, including one that appears after a client re-render. Any other block can opt out with `data-tome-reveal="off"` on its root element. `startTomeReveal()` runs the same controller outside React.

  • 1767d0d: New opt-in scroll reveal: mount `TomeReveal` from `@wabbit/tome-ui/reveal` once, and turn it on with `data-tome-reveal="on"` or a theme's `--tome-reveal: on`. Block roots (`[data-block-type]`) rise and fade in once as they enter the viewport, with blocks that enter together staggered. Inner parts marked `data-tome-reveal-item` stagger in after their block. Timing comes from four new tokens with neutral defaults in `tokens.css`: `--tome-reveal-rise` (16px), `--tome-reveal-duration` (500ms), `--tome-reveal-ease` and `--tome-reveal-stagger` (80ms). The styles ship as `@wabbit/tome-ui/reveal.css`, which `@wabbit/tome-ui/base` now imports. Additive: the reveal is off by default, and a page that does not opt in renders exactly as before. When it is on, nothing is hidden without JavaScript, under `prefers-reduced-motion: reduce`, in print, or above the fold when the page loads. Only `opacity` and `transform` change, so there is no layout shift. Blocks that animate themselves are skipped automatically: a motion component marks its own root with `data-tome-motion="self"`, and the reveal never arms a block containing one, including one that appears after a client re-render. Any other block can opt out with `data-tome-reveal="off"` on its root element. `startTomeReveal()` runs the same controller outside React.
v0.17.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.17.0minor

d432a85: New optional `@wabbit/tome-ui/console.css` gives the dark console blocks a light panel in your light theme, opted into with `data-tome-console="theme"` on `<html>` or any wrapper. Purely additive: a new stylesheet and two new helpers (`consoleAccentVars`, `resolveConsoleAccent`) in `./utils/accent`; no existing export, default or peer changes. The sheet declares a documented set of `--tome-console-*` role names: surface, raised surface, line, three ink steps, seven accent hues as text, identity and on-fill, five statuses, two classification bands, and the panel-composition roles (top-rule width and strength, hairline, status edge and bar, tint strength, SECRET fill strength, band rules, badge outline, status-word display, dim opacity). Importing it alone changes nothing: every rule is scoped to the attribute. In the light theme the neutral roles derive from your theme's card, border and ink tokens, and the hue inks are fixed values that clear 4.5:1 on the default card. A `[data-theme="dark"]` page, island or opt-in element, and any `@wabbit/tome-cop` scope on, above or below the opt-in, reset every role, so dark consoles and cop-themed consoles render exactly as before. `consoleAccentVars` and `resolveConsoleAccent` return the same tokens as `accentVars` and `resolveAccent`, with the text color read through `--tome-console-accent-<hue>-on-surface` and the border through `--tome-console-accent-<hue>` first. With the roles unset they compute the same colors. `accentVars` and `resolveAccent` are unchanged. The role table is in the README.

  • d432a85: New optional `@wabbit/tome-ui/console.css` gives the dark console blocks a light panel in your light theme, opted into with `data-tome-console="theme"` on `<html>` or any wrapper. Purely additive: a new stylesheet and two new helpers (`consoleAccentVars`, `resolveConsoleAccent`) in `./utils/accent`; no existing export, default or peer changes. The sheet declares a documented set of `--tome-console-*` role names: surface, raised surface, line, three ink steps, seven accent hues as text, identity and on-fill, five statuses, two classification bands, and the panel-composition roles (top-rule width and strength, hairline, status edge and bar, tint strength, SECRET fill strength, band rules, badge outline, status-word display, dim opacity). Importing it alone changes nothing: every rule is scoped to the attribute. In the light theme the neutral roles derive from your theme's card, border and ink tokens, and the hue inks are fixed values that clear 4.5:1 on the default card. A `[data-theme="dark"]` page, island or opt-in element, and any `@wabbit/tome-cop` scope on, above or below the opt-in, reset every role, so dark consoles and cop-themed consoles render exactly as before. `consoleAccentVars` and `resolveConsoleAccent` return the same tokens as `accentVars` and `resolveAccent`, with the text color read through `--tome-console-accent-<hue>-on-surface` and the border through `--tome-console-accent-<hue>` first. With the roles unset they compute the same colors. `accentVars` and `resolveAccent` are unchanged. The role table is in the README.
v0.16.0minor

c14a133: The page grid now also ships as a plain stylesheet, `@wabbit/tome-ui/grid.global.css`, with the global class `.tome-grid`. The grid's named lines (`content-start`, `reading-start`, `full-start`, and the rest) used to be reachable only through the `./grid` CSS Module, whose hashed class has to be imported into a component. `grid.global.css` is the same grid, generated from `grid.module.css` at build, so it can travel in an ordinary CSS `@import`. Only the grid container and its `[data-tome-block-wrapper]` defaults are included; the placement utilities (`start*`, `span*`, …) stay in the module. `./grid` is unchanged.

  • c14a133: The page grid now also ships as a plain stylesheet, `@wabbit/tome-ui/grid.global.css`, with the global class `.tome-grid`. The grid's named lines (`content-start`, `reading-start`, `full-start`, and the rest) used to be reachable only through the `./grid` CSS Module, whose hashed class has to be imported into a component. `grid.global.css` is the same grid, generated from `grid.module.css` at build, so it can travel in an ordinary CSS `@import`. Only the grid container and its `[data-tome-block-wrapper]` defaults are included; the placement utilities (`start*`, `span*`, …) stay in the module. `./grid` is unchanged.
v0.15.0minor

8c84e70: Adds the rest of the ink set for the two theme-invariant surfaces, so text on a band that does not flip with the theme can use inks that do not flip either. - `--tome-color-on-solid-dark-muted` and `--tome-color-on-inverse-muted`: single, invariant muted inks (8.0:1 on black, 10.0:1 on white). - `--tome-color-accent-on-solid-dark`: accent-text with its OKLCH lightness floored at 0.66, hue and chroma kept, the same derivation as `--tome-color-primary-on-solid-dark`. A dark theme's light accent-text passes through unchanged. - `--tome-color-accent-on-inverse` and `--tome-color-primary-on-inverse`: accent-text and primary with lightness capped at 0.5. A light theme's dark accent passes through unchanged. - Optional site inputs `--accent-on-solid-dark`, `--accent-on-inverse` and `--primary-on-inverse` pin an exact value. A theme whose accent changes hue between light and dark should pin `--accent-on-solid-dark`, so its dark bands look the same in both modes. Every existing token keeps its value.

  • 8c84e70: Adds the rest of the ink set for the two theme-invariant surfaces, so text on a band that does not flip with the theme can use inks that do not flip either. - `--tome-color-on-solid-dark-muted` and `--tome-color-on-inverse-muted`: single, invariant muted inks (8.0:1 on black, 10.0:1 on white). - `--tome-color-accent-on-solid-dark`: accent-text with its OKLCH lightness floored at 0.66, hue and chroma kept, the same derivation as `--tome-color-primary-on-solid-dark`. A dark theme's light accent-text passes through unchanged. - `--tome-color-accent-on-inverse` and `--tome-color-primary-on-inverse`: accent-text and primary with lightness capped at 0.5. A light theme's dark accent passes through unchanged. - Optional site inputs `--accent-on-solid-dark`, `--accent-on-inverse` and `--primary-on-inverse` pin an exact value. A theme whose accent changes hue between light and dark should pin `--accent-on-solid-dark`, so its dark bands look the same in both modes. Every existing token keeps its value.
v0.14.1patch

8c69bf4: Fix: site font overrides no longer disappear inside a theme. 0.14.0's generated Layer-2 re-resolution block (`:where([data-tome-theme], [data-tome-pack])`) re-derived `--tome-type-sans/serif/mono/display` from `--font-*` on every theme wrapper, which threw away any site that sets those Layer-2 tokens at `:root` (the starter's `--tome-type-display`, wabbit-site-core's `tome-overrides.css`). Under Industrial, the starter's Instrument Serif headings fell back to Instrument Sans. The four font families are now flagged `{ "reresolve": false }` in `tokens/layer2-aliases.json`: still verified and aliased at `:root`, no longer re-declared in the block. The 29 color and radius aliases are unchanged. Rule for theme authors: a theme that changes fonts declares each `--tome-type-*` next to the matching `--font-*` on its own selector (for example `--tome-type-display: var(--font-display);`). `assert-theme-conformance` now fails a theme stylesheet that sets a `--font-*` slot without the matching `--tome-type-*` under the same selector.

  • 8c69bf4: Fix: site font overrides no longer disappear inside a theme. 0.14.0's generated Layer-2 re-resolution block (`:where([data-tome-theme], [data-tome-pack])`) re-derived `--tome-type-sans/serif/mono/display` from `--font-*` on every theme wrapper, which threw away any site that sets those Layer-2 tokens at `:root` (the starter's `--tome-type-display`, wabbit-site-core's `tome-overrides.css`). Under Industrial, the starter's Instrument Serif headings fell back to Instrument Sans. The four font families are now flagged `{ "reresolve": false }` in `tokens/layer2-aliases.json`: still verified and aliased at `:root`, no longer re-declared in the block. The 29 color and radius aliases are unchanged. Rule for theme authors: a theme that changes fonts declares each `--tome-type-*` next to the matching `--font-*` on its own selector (for example `--tome-type-display: var(--font-display);`). `assert-theme-conformance` now fails a theme stylesheet that sets a `--font-*` slot without the matching `--tome-type-*` under the same selector.
v0.14.0minor

9f6b52c: A theme activated with `data-tome-theme` on any element, not only `<html>`, now re-themes the blocks inside it. `tokens.css` re-declares the var()-aliased Layer-2 tokens under `[data-tome-theme]` (and the deprecated `[data-tome-pack]`). Layer 2 is computed where it is declared: the `:root` alias block resolves once at `<html>`, so a Layer-1 override on a descendant never reached the `--tome-color-*` values blocks read unless each theme hand-copied the whole alias block. The re-declaration is generated from `src/tokens/layer2-aliases.json` (33 aliases, including `--tome-color-chart-1..5` and `--tome-type-serif`) at zero specificity (`:where()`), so any theme rule for the same names wins by specificity, never by load order. `test` and `prepublishOnly` fail if `tokens.css` drifts from the map. The tokens.css header's Layer-3 note is corrected accordingly: subtree `[data-theme]` inversions are still not re-resolved. Also in this release: - `--tome-radius-none` (0), `--tome-radius-xl` (1rem) and `--tome-motion-normal` (alias of `--tome-motion-base`) are now declared. Blocks already read them without a fallback, so those corners and transitions previously resolved to nothing. - Headings h1–h4 in `base.css` read an optional `--tome-type-weight-display` before their existing weight. Unset (the default), nothing changes; a theme or ThemeConfig sets it to change display weight. - The app-shell sidebar fallback now reads `--tome-color-surface` (it read the undeclared `--tome-color-card`). - **Deprecated:** `./packs/*` (`editorial`, `industrial-brutalist`, `signal`). They were empty stubs; they now contain only a deprecation comment, are removed from Storybook, and will be deleted with the export in a later minor. Themes ship as their own packages, activated by `data-tome-theme`.

  • 9f6b52c: A theme activated with `data-tome-theme` on any element, not only `<html>`, now re-themes the blocks inside it. `tokens.css` re-declares the var()-aliased Layer-2 tokens under `[data-tome-theme]` (and the deprecated `[data-tome-pack]`). Layer 2 is computed where it is declared: the `:root` alias block resolves once at `<html>`, so a Layer-1 override on a descendant never reached the `--tome-color-*` values blocks read unless each theme hand-copied the whole alias block. The re-declaration is generated from `src/tokens/layer2-aliases.json` (33 aliases, including `--tome-color-chart-1..5` and `--tome-type-serif`) at zero specificity (`:where()`), so any theme rule for the same names wins by specificity, never by load order. `test` and `prepublishOnly` fail if `tokens.css` drifts from the map. The tokens.css header's Layer-3 note is corrected accordingly: subtree `[data-theme]` inversions are still not re-resolved. Also in this release: - `--tome-radius-none` (0), `--tome-radius-xl` (1rem) and `--tome-motion-normal` (alias of `--tome-motion-base`) are now declared. Blocks already read them without a fallback, so those corners and transitions previously resolved to nothing. - Headings h1–h4 in `base.css` read an optional `--tome-type-weight-display` before their existing weight. Unset (the default), nothing changes; a theme or ThemeConfig sets it to change display weight. - The app-shell sidebar fallback now reads `--tome-color-surface` (it read the undeclared `--tome-color-card`). - **Deprecated:** `./packs/*` (`editorial`, `industrial-brutalist`, `signal`). They were empty stubs; they now contain only a deprecation comment, are removed from Storybook, and will be deleted with the export in a later minor. Themes ship as their own packages, activated by `data-tome-theme`.
v0.13.2patch

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.
v0.13.1patch

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.13.0minor

befde64: Add the numeric block spacing scale (`--tome-space-0-5` … `--tome-space-16`) to `tokens.css`, additive alongside the existing t-shirt scale. Ported from wabbit-site-core's local `tome-overrides.css`, which had been carrying this scale on its own for the ~92 SCSS modules (2868 references) and `@wabbit/tome-blocks-gallery` that already consume it — this makes tome-ui the canonical source instead of each site re-authoring the same table. Consumed by `@wabbit/tome-blocks-house`'s ported partials (block-house-primitives B0).

  • befde64: Add the numeric block spacing scale (`--tome-space-0-5` … `--tome-space-16`) to `tokens.css`, additive alongside the existing t-shirt scale. Ported from wabbit-site-core's local `tome-overrides.css`, which had been carrying this scale on its own for the ~92 SCSS modules (2868 references) and `@wabbit/tome-blocks-gallery` that already consume it — this makes tome-ui the canonical source instead of each site re-authoring the same table. Consumed by `@wabbit/tome-blocks-house`'s ported partials (block-house-primitives B0).
v0.12.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • 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.
  • 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.
v0.11.2patch

48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
v0.11.1patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.11.0minor

0a070e0: **VISIBLE CHANGE above 2118px viewport width.** The ultra-wide rule in `tokens.css` was `@media (min-width: 2000px) { html { font-size: 0.85vw } }`, headed "global downscale so rem-based layouts don't stretch on large monitors". Against the 18px base in `base.css` it only downscales in the narrow 2000–2118px band — past that it is unbounded growth: 21.8px at 2560, 29.2px at 3440, 43.5px at 5120. Because it sets the ROOT size, every rem-derived length in every consumer inflated with it (2.4x at 5K2K), which reads as "the fonts scale with my window and the layout breaks at fullscreen". Now `min(0.85vw, 18px)`. The intended downscale band is byte-identical (17px at 2000px) and the root is clamped at the base, so it can shrink on wide monitors but never exceed what `base.css` sets. Sites that were unknowingly designed against the inflated root — anything laid out and eyeballed at 2560px or wider — will render smaller after upgrading, because that inflation was the defect. A visual pass at 2560px+ is recommended before adopting. The 18px ceiling mirrors `base.css`'s `html { font-size }`; keep them in sync. Surfaced by a consumer's member on a 5120x2160 display; root cause confirmed with CDP `CSS.getMatchedStylesForNode` against production rather than a source grep.

  • 0a070e0: **VISIBLE CHANGE above 2118px viewport width.** The ultra-wide rule in `tokens.css` was `@media (min-width: 2000px) { html { font-size: 0.85vw } }`, headed "global downscale so rem-based layouts don't stretch on large monitors". Against the 18px base in `base.css` it only downscales in the narrow 2000–2118px band — past that it is unbounded growth: 21.8px at 2560, 29.2px at 3440, 43.5px at 5120. Because it sets the ROOT size, every rem-derived length in every consumer inflated with it (2.4x at 5K2K), which reads as "the fonts scale with my window and the layout breaks at fullscreen". Now `min(0.85vw, 18px)`. The intended downscale band is byte-identical (17px at 2000px) and the root is clamped at the base, so it can shrink on wide monitors but never exceed what `base.css` sets. Sites that were unknowingly designed against the inflated root — anything laid out and eyeballed at 2560px or wider — will render smaller after upgrading, because that inflation was the defect. A visual pass at 2560px+ is recommended before adopting. The 18px ceiling mirrors `base.css`'s `html { font-size }`; keep them in sync. Surfaced by a consumer's member on a 5120x2160 display; root cause confirmed with CDP `CSS.getMatchedStylesForNode` against production rather than a source grep.
v0.10.0minor

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.
v0.9.9patch

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: 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.
  • aef2725: Accent layer unified: `accentVars()` (accent → `--block-accent-*` CSS custom properties) is now canonical in `@wabbit/tome-ui/utils/accent`; dispatch/readout re-export it and their ~19 inline style-object constructions now call it (values byte-identical for both). **longform: VISIBLE CHANGE (hence minor)** — its local ACCENT_MAP had drifted from the canonical palette its own header declared as the migration target; completing the migration shifts longform block accent hues slightly, makes borders match text, switches backgrounds from solid pale to translucent color-mix, and longform now honors `--cop-accent-*` theme overrides for the first time (parity with dispatch/readout). A visual pass on Callout/KeyFacts/DataTable-class blocks is recommended before adopting in a styled site.
  • 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.
v0.9.8patch

ec4b7bc: Layer-1 font slots (T3): `--tome-type-sans/serif/mono/display` now route through `:root`-defined `--font-sans/serif/mono/display` with the identical literal stacks as defaults — resolved values unchanged; theme packs can now override font families via the same layer1-override + layer2-re-emission mechanism they use for color.

  • ec4b7bc: Layer-1 font slots (T3): `--tome-type-sans/serif/mono/display` now route through `:root`-defined `--font-sans/serif/mono/display` with the identical literal stacks as defaults — resolved values unchanged; theme packs can now override font families via the same layer1-override + layer2-re-emission mechanism they use for color.
v0.9.7patch

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.
v0.9.6patch

Breakout chrome graduation (additive): `resolveBreakout(value, opts)` gains `{ aliasMode, pinnedBand, defaultWidth }`; new `resolveContentPlacement()` + `tome-cw-3..7` in `breakout.css` (the inner content-width axis → `--tome-content-cols`); new `withBlockPlacement` chrome HOC at `@wabbit/tome-ui/utils/withBlockPlacement`. Existing consumers ride the byte-identical named-token facade unchanged.

  • Breakout chrome graduation (additive): `resolveBreakout(value, opts)` gains `{ aliasMode, pinnedBand, defaultWidth }`; new `resolveContentPlacement()` + `tome-cw-3..7` in `breakout.css` (the inner content-width axis → `--tome-content-cols`); new `withBlockPlacement` chrome HOC at `@wabbit/tome-ui/utils/withBlockPlacement`. Existing consumers ride the byte-identical named-token facade unchanged.
v0.9.5patch

D3 breakout platform foundation: additive canonical breakout resolver (`resolveBreakout`, `normalizeWidth`, `BREAKOUT_LADDER_OPTIONS`) + exported `@wabbit/tome-ui/breakout.css` relax classes. `resolveBreakoutWidth`/`breakoutWidthField` preserved as a byte-identical facade (longform/editorial/readout unchanged).

  • D3 breakout platform foundation: additive canonical breakout resolver (`resolveBreakout`, `normalizeWidth`, `BREAKOUT_LADDER_OPTIONS`) + exported `@wabbit/tome-ui/breakout.css` relax classes. `resolveBreakoutWidth`/`breakoutWidthField` preserved as a byte-identical facade (longform/editorial/readout unchanged).
v0.9.3patch

84a047a: Fix NavigationMenu indicator leaving an 8px sliver peeking below the bar after a mega-menu/dropdown closes. The closed indicator (`[data-state='hidden']`) now has an explicit resting `opacity: 0` + `pointer-events: none`, so it stays hidden once its exit animation (which has no `forwards` fill) completes instead of reverting to the base `opacity: 1`.

  • 84a047a: Fix NavigationMenu indicator leaving an 8px sliver peeking below the bar after a mega-menu/dropdown closes. The closed indicator (`[data-state='hidden']`) now has an explicit resting `opacity: 0` + `pointer-events: none`, so it stays hidden once its exit animation (which has no `forwards` fill) completes instead of reverting to the base `opacity: 1`.
v0.9.2patch

8947ff1: Three additive packaging fixes surfaced by a consumer's registry-consumption migration (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible.

  • 8947ff1: Three additive packaging fixes surfaced by a consumer's registry-consumption migration (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible.
v0.9.1patch

feat(navigation-menu): overridable panel surface via CSS vars `NavigationMenu`'s `.content` and `.viewport` now read `--tome-nav-surface-bg`, `--tome-nav-surface-border`, and `--tome-nav-surface-shadow`, each falling back to the existing popover tokens (`--tome-color-popover` / `--tome-color-border` / `--tome-shadow-md`). Unset = byte-identical to before. Lets a consumer (e.g. a navbar variant) recolor the dropdown/mega-menu panel — or drop its border — by setting those vars on any ancestor, without forking the primitive. Consumed by `@wabbit/tome-chrome` NavBar4's new mega-menu background-color field.

  • feat(navigation-menu): overridable panel surface via CSS vars `NavigationMenu`'s `.content` and `.viewport` now read `--tome-nav-surface-bg`, `--tome-nav-surface-border`, and `--tome-nav-surface-shadow`, each falling back to the existing popover tokens (`--tome-color-popover` / `--tome-color-border` / `--tome-shadow-md`). Unset = byte-identical to before. Lets a consumer (e.g. a navbar variant) recolor the dropdown/mega-menu panel — or drop its border — by setting those vars on any ancestor, without forking the primitive. Consumed by `@wabbit/tome-chrome` NavBar4's new mega-menu background-color field.
v0.9.0minor

Breakout widths now resolve to **named grid lines**, not pixel max-width caps. `resolveBreakoutWidth` (`@wabbit/tome-ui/utils/breakout`) returns `{ gridColumn }` (e.g. `prose-start / prose-end`); blocks apply it as `style={{ gridColumn }}` on a subgrid root. Field option/value strings unchanged (no data migration). `BreakoutWidthValue`/`ResolvedBreakoutWidth` exported; `BreakoutWidth` kept as a deprecated alias. Breaking for consumers reading `.maxWidth`/`.width` off the result.

  • Breakout widths now resolve to **named grid lines**, not pixel max-width caps. `resolveBreakoutWidth` (`@wabbit/tome-ui/utils/breakout`) returns `{ gridColumn }` (e.g. `prose-start / prose-end`); blocks apply it as `style={{ gridColumn }}` on a subgrid root. Field option/value strings unchanged (no data migration). `BreakoutWidthValue`/`ResolvedBreakoutWidth` exported; `BreakoutWidth` kept as a deprecated alias. Breaking for consumers reading `.maxWidth`/`.width` off the result.
  • Added `--tome-type-leading-{none,tight,snug,normal,relaxed}` line-height aliases (retro-fixes existing block CSS) and a `--tome-color-surface-tint` token.
v0.8.3patch

0b2a1d6: grid: change platform marginalia defaults from asymmetric to symmetric. `--tome-grid-marginalia-left-cols` default goes from `2` to `3` at lg+ (lg/xl/2xl/3xl). `--tome-grid-marginalia-right-cols` default goes from `4` to `3` at 2xl/3xl (lg/xl was already `3`). Net effect: M_L=M_R=3 across all lg+ breakpoints, producing symmetric prose center (line 10 of the 18-track grid) by default. Retires the wider-right-margin editorial convention from the 2026-05-10 marginalia tracks spec — consumer pattern across Wabbit content routes showed every active route needed an override toward symmetry. Non-breaking for any consumer that already overrides marginalia. Visible defaults change: prose narrows by 1 col at lg+ (7→6) and 2 cols at 2xl+ (8→6) for consumers without `--tome-prose-max-width` cap; right marginalia narrows by 1 col at 2xl+ (4→3). See spec `2026-05-13-tome-ui-grid-symmetric-marginalia-defaults-design` for the full design rationale, per-consumer audit, and migration path. Wabbit chapter route's existing `--tome-grid-marginalia-left-cols: 3` override becomes redundant post-publish (optional cleanup); Wabbit Studies/Pages/Posts wider-reading override (M_L=M_R=2, P_pad=3) stays as-is.

  • 0b2a1d6: grid: change platform marginalia defaults from asymmetric to symmetric. `--tome-grid-marginalia-left-cols` default goes from `2` to `3` at lg+ (lg/xl/2xl/3xl). `--tome-grid-marginalia-right-cols` default goes from `4` to `3` at 2xl/3xl (lg/xl was already `3`). Net effect: M_L=M_R=3 across all lg+ breakpoints, producing symmetric prose center (line 10 of the 18-track grid) by default. Retires the wider-right-margin editorial convention from the 2026-05-10 marginalia tracks spec — consumer pattern across Wabbit content routes showed every active route needed an override toward symmetry. Non-breaking for any consumer that already overrides marginalia. Visible defaults change: prose narrows by 1 col at lg+ (7→6) and 2 cols at 2xl+ (8→6) for consumers without `--tome-prose-max-width` cap; right marginalia narrows by 1 col at 2xl+ (4→3). See spec `2026-05-13-tome-ui-grid-symmetric-marginalia-defaults-design` for the full design rationale, per-consumer audit, and migration path. Wabbit chapter route's existing `--tome-grid-marginalia-left-cols: 3` override becomes redundant post-publish (optional cleanup); Wabbit Studies/Pages/Posts wider-reading override (M_L=M_R=2, P_pad=3) stays as-is.
v0.8.2patch

4225e9f: grid: re-alias `breakout-md-start/end` and `breakout-lg-start/end` named lines to point at the existing `reading` and `content` tracks respectively. Pure additive line-name remap — no track count change at any breakpoint, no integer column index shifts. Resolves the documented gap where both `breakoutWidth='breakout-md'` and `'breakout-lg'` rendered identically to `'full-bleed'` (both aliased to `full-start/end` previously). Result: 4 distinct column-aligned widths from the 5 enum values exposed by `@wabbit/tome-longform/utils/breakout`'s `breakoutWidthField`, with `breakout-lg` now a documented synonym for `content`. At base/sm where `reading-start/end` is not declared, both `breakout-md-start/end` and `breakout-lg-start/end` collapse to `content-start/end` (graceful mobile fallback, matching the prose-track pattern). All marginalia and prose named lines unchanged. See spec `2026-05-12-tome-ui-grid-breakout-rings-design` for the full design rationale, per-breakpoint diffs, and per-consumer audit.

  • 4225e9f: grid: re-alias `breakout-md-start/end` and `breakout-lg-start/end` named lines to point at the existing `reading` and `content` tracks respectively. Pure additive line-name remap — no track count change at any breakpoint, no integer column index shifts. Resolves the documented gap where both `breakoutWidth='breakout-md'` and `'breakout-lg'` rendered identically to `'full-bleed'` (both aliased to `full-start/end` previously). Result: 4 distinct column-aligned widths from the 5 enum values exposed by `@wabbit/tome-longform/utils/breakout`'s `breakoutWidthField`, with `breakout-lg` now a documented synonym for `content`. At base/sm where `reading-start/end` is not declared, both `breakout-md-start/end` and `breakout-lg-start/end` collapse to `content-start/end` (graceful mobile fallback, matching the prose-track pattern). All marginalia and prose named lines unchanged. See spec `2026-05-12-tome-ui-grid-breakout-rings-design` for the full design rationale, per-breakpoint diffs, and per-consumer audit.
v0.8.1patch

fix(ui): block-wrapper defaults to `pointer-events: none` in `.grid` context `[data-tome-block-wrapper]` (RenderBlocks' full-width subgrid wrapper, `grid-column: 1 / -1`) now defaults to `pointer-events: none` inside `.grid` contexts; direct child gets `pointer-events: auto` restored. The wrapper's bounding box is the full content width regardless of which column the visible inner content occupies, so without this default the wrapper's invisible area intercepts clicks intended for underlying chrome — sidebars, chapter nav, marginalia panels rendered as siblings. ```css /* shipped in grid.module.css */ :where(.grid :global([data-tome-block-wrapper])) { pointer-events: none; } :where(.grid :global([data-tome-block-wrapper]) > *) { grid-column: 2 / -2; /* unchanged */ pointer-events: auto; /* new */ } ``` The rule is `:where()`-wrapped (specificity 0) so consumers can still override when a wrapper genuinely needs to capture clicks. Retires the per-route `[data-tome-block-wrapper] { pointer-events: none }` band-aid pattern that an earlier wabbit-site-core fix introduced. See prose-track spec amendment §12.7 (2026-05-11) for the symptom that surfaced this and §12.7 fix 1 for the deferred-then-built architectural decision.

  • fix(ui): block-wrapper defaults to `pointer-events: none` in `.grid` context `[data-tome-block-wrapper]` (RenderBlocks' full-width subgrid wrapper, `grid-column: 1 / -1`) now defaults to `pointer-events: none` inside `.grid` contexts; direct child gets `pointer-events: auto` restored. The wrapper's bounding box is the full content width regardless of which column the visible inner content occupies, so without this default the wrapper's invisible area intercepts clicks intended for underlying chrome — sidebars, chapter nav, marginalia panels rendered as siblings. ```css /* shipped in grid.module.css */ :where(.grid :global([data-tome-block-wrapper])) { pointer-events: none; } :where(.grid :global([data-tome-block-wrapper]) > *) { grid-column: 2 / -2; /* unchanged */ pointer-events: auto; /* new */ } ``` The rule is `:where()`-wrapped (specificity 0) so consumers can still override when a wrapper genuinely needs to capture clicks. Retires the per-route `[data-tome-block-wrapper] { pointer-events: none }` band-aid pattern that an earlier wabbit-site-core fix introduced. See prose-track spec amendment §12.7 (2026-05-11) for the symptom that surfaced this and §12.7 fix 1 for the deferred-then-built architectural decision.
v0.8.0minor

feat(ui): grid prose track — `prose-start / prose-end` named lines + `--tome-prose-max-width` cap + missing marginalia `-start/-end` aliases `@wabbit/tome-ui/grid` `.grid` template now declares a first-class prose track at md+ breakpoints. tome-longform 0.3.0 retargets 13 of 16 block `.blockRoot` defaults to `grid-column: prose-start / prose-end`. Consumers can pin prose to a hard pixel width via `--tome-prose-max-width` (per spec 2026-05-11-tome-ui-prose-track-design). Default prose-pad widths per breakpoint (`P_pad` cols each side of `prose-inner`; configurable via `--tome-grid-prose-pad-cols` at lg+, defaults to 2): - md (reading=6): P_pad=1, P_inner=4 - lg+ (reading=11): P_pad=2, P_inner=7 (~800px @ 1920 viewport, ~70ch at body font) - 2xl+ (reading=10): P_pad=2, P_inner=6 - base+sm: prose-start ≡ content-start, prose-end ≡ content-end (collapse to full readable column on phones) Consumer override pattern: ```css /* on a route or layout wrapper */ .contentWrapper { --tome-prose-max-width: 600px; } /* block CSS, set by tome-longform 0.3.0 */ .blockRoot { max-width: var(--tome-prose-max-width, none); margin-inline: auto; } ```

  • feat(ui): grid prose track — `prose-start / prose-end` named lines + `--tome-prose-max-width` cap + missing marginalia `-start/-end` aliases `@wabbit/tome-ui/grid` `.grid` template now declares a first-class prose track at md+ breakpoints. tome-longform 0.3.0 retargets 13 of 16 block `.blockRoot` defaults to `grid-column: prose-start / prose-end`. Consumers can pin prose to a hard pixel width via `--tome-prose-max-width` (per spec 2026-05-11-tome-ui-prose-track-design). Default prose-pad widths per breakpoint (`P_pad` cols each side of `prose-inner`; configurable via `--tome-grid-prose-pad-cols` at lg+, defaults to 2): - md (reading=6): P_pad=1, P_inner=4 - lg+ (reading=11): P_pad=2, P_inner=7 (~800px @ 1920 viewport, ~70ch at body font) - 2xl+ (reading=10): P_pad=2, P_inner=6 - base+sm: prose-start ≡ content-start, prose-end ≡ content-end (collapse to full readable column on phones) Consumer override pattern: ```css /* on a route or layout wrapper */ .contentWrapper { --tome-prose-max-width: 600px; } /* block CSS, set by tome-longform 0.3.0 */ .blockRoot { max-width: var(--tome-prose-max-width, none); margin-inline: auto; } ```
  • feat(ui): coalesce missing marginalia `-start/-end` aliases on the grid template (md+) The marginalia design spec (2026-05-10 §3.2) declared `marginalia-{left,right}-{start,end}` aliases for the `-{outer,inner}` line positions, but those alias names never actually shipped in `grid.module.css` in 0.7.0. KeyFacts SIDEBAR + Aside-right + Aside-left + AuthorAside-overlay variants in tome-longform 0.2.0 targeted `marginalia-right-start / marginalia-right-end` (and the left equivalents) and currently fall through to single grid cells on non-bandaided routes. 0.8.0 ships the missing aliases — `marginalia-left-start ≡ marginalia-left-outer`, `marginalia-left-end ≡ marginalia-left-inner`, `marginalia-right-start ≡ marginalia-right-inner`, `marginalia-right-end ≡ marginalia-right-outer` — coalesced into the existing line brackets at md/lg/xl/2xl/3xl. Fixes all 4 longform variants without a per-block CSS edit in tome-longform 0.3.0.
  • `repeat(calc(...))` browser support: Chrome 117+, Firefox 119+, Safari 17.4+ — same envelope as 0.7.0's marginalia calc; one additional subtraction term for `--tome-grid-prose-pad-cols * 2`. Verified locally; cross-browser smoke gates at G1.
  • No breaking changes to existing line names. All additions are coalesced with existing positions or new line names declared inside existing brackets.
v0.7.0minor

feat(ui): grid marginalia tracks — `marginalia-{left,right}-{outer,inner}` + `reading-{start,end}` named lines `@wabbit/tome-ui/grid` `.grid` template now exposes a first-class marginalia track system at md+ breakpoints. Block packs that previously placed sidebar variants at the page padding columns (`margin-{left,right}-*`, ~24px wide) can now place at `marginalia-{left,right}-*` for a readable editorial column inside the inner content grid. Default widths per breakpoint: - md (≥768): M_left=0 (collapsed to content-start), M_right=2; reading=6 - lg+ (≥1024): M_left=2, M_right=3; reading=11 - 2xl+ (≥1536): M_left=2, M_right=4; reading=10 - base+sm: marginalia variants gate at md+ in pack CSS; below md they fall through to default content-area placement Routes can override the lg+ defaults via `--tome-grid-marginalia-left-cols` / `--tome-grid-marginalia-right-cols` custom properties on the grid wrapper. Implementation uses `calc()` inside `repeat()` (CSS Values L4; Chrome 117+, Firefox 119+, Safari 17.4+). Reading-column-only aliases also added: `reading-start` ≡ `marginalia-left-inner`; `reading-end` ≡ `marginalia-right-inner`. Use these when a block wants to align with the reading column even when marginalia is present. Backward-compat: `content-start/end`, `full-*`, `breakout-{md,lg}-*`, `margin-{left,right}-*` named lines unchanged. `margin-*` retains padding-column placement for blocks that want gutter rendering specifically. Aside.LEFT-style variants at md viewport resolve to 0 width (M_left=0 there); they effectively activate at lg+. Documented as a known limitation pending a future spec revision.

  • feat(ui): grid marginalia tracks — `marginalia-{left,right}-{outer,inner}` + `reading-{start,end}` named lines `@wabbit/tome-ui/grid` `.grid` template now exposes a first-class marginalia track system at md+ breakpoints. Block packs that previously placed sidebar variants at the page padding columns (`margin-{left,right}-*`, ~24px wide) can now place at `marginalia-{left,right}-*` for a readable editorial column inside the inner content grid. Default widths per breakpoint: - md (≥768): M_left=0 (collapsed to content-start), M_right=2; reading=6 - lg+ (≥1024): M_left=2, M_right=3; reading=11 - 2xl+ (≥1536): M_left=2, M_right=4; reading=10 - base+sm: marginalia variants gate at md+ in pack CSS; below md they fall through to default content-area placement Routes can override the lg+ defaults via `--tome-grid-marginalia-left-cols` / `--tome-grid-marginalia-right-cols` custom properties on the grid wrapper. Implementation uses `calc()` inside `repeat()` (CSS Values L4; Chrome 117+, Firefox 119+, Safari 17.4+). Reading-column-only aliases also added: `reading-start` ≡ `marginalia-left-inner`; `reading-end` ≡ `marginalia-right-inner`. Use these when a block wants to align with the reading column even when marginalia is present. Backward-compat: `content-start/end`, `full-*`, `breakout-{md,lg}-*`, `margin-{left,right}-*` named lines unchanged. `margin-*` retains padding-column placement for blocks that want gutter rendering specifically. Aside.LEFT-style variants at md viewport resolve to 0 width (M_left=0 there); they effectively activate at lg+. Documented as a known limitation pending a future spec revision.
v0.6.1patch

1d90b24: Add `breakout-md-{start,end}` and `breakout-lg-{start,end}` named lines to the page grid template at all 7 breakpoints. `breakout-lg` aliases `full` (full viewport, padding-to-padding). `breakout-md` aliases `margin-left-start / margin-right-end` — wider than content, narrower than full. Distinct from `breakout-lg` only by semantic intent at this grid resolution; no in-between track exists yet. Adopted by `@wabbit/tome-longform`'s DataTable + ImageGrid breakout-width variants (`breakout-md`, `breakout-lg`, `full-bleed`). Additive — no impact on existing consumers.

  • 1d90b24: Add `breakout-md-{start,end}` and `breakout-lg-{start,end}` named lines to the page grid template at all 7 breakpoints. `breakout-lg` aliases `full` (full viewport, padding-to-padding). `breakout-md` aliases `margin-left-start / margin-right-end` — wider than content, narrower than full. Distinct from `breakout-lg` only by semantic intent at this grid resolution; no in-between track exists yet. Adopted by `@wabbit/tome-longform`'s DataTable + ImageGrid breakout-width variants (`breakout-md`, `breakout-lg`, `full-bleed`). Additive — no impact on existing consumers.
v0.5.0minor

**`grid.module.css`** — flip the default block-content placement from full-bleed (`1 / -1`) to content-area (`2 / -2`), and zero its specificity so per-block declarations always win. The `[data-tome-block-wrapper]` subgrid still spans `1 / -1` of the page grid (full-bleed access preserved), but the _block content_ inside the wrapper now defaults to the content columns. Pack blocks intentionally rendering full-bleed (Marquee, ImageMarquee, Showcase, hero-style packs) already declare `grid-column: 1 / -1` on their own root container — those declarations now reliably win because the platform default is wrapped in `:where()` (specificity 0). **Why minor, not patch:** this changes the rendered layout for any consumer relying on the previous `1 / -1` default for non-pack blocks. Most blocks should be content-area; full-bleed is the exception and should be opted into explicitly.

  • **`grid.module.css`** — flip the default block-content placement from full-bleed (`1 / -1`) to content-area (`2 / -2`), and zero its specificity so per-block declarations always win. The `[data-tome-block-wrapper]` subgrid still spans `1 / -1` of the page grid (full-bleed access preserved), but the _block content_ inside the wrapper now defaults to the content columns. Pack blocks intentionally rendering full-bleed (Marquee, ImageMarquee, Showcase, hero-style packs) already declare `grid-column: 1 / -1` on their own root container — those declarations now reliably win because the platform default is wrapped in `:where()` (specificity 0). **Why minor, not patch:** this changes the rendered layout for any consumer relying on the previous `1 / -1` default for non-pack blocks. Most blocks should be content-area; full-bleed is the exception and should be opted into explicitly.
v0.4.2patch

**Anchor block-wrapper default selector on `.grid` (CSS-Modules pure-selector compliance).** `grid.module.css` had a pure-global selector at the block-wrapper default rule (`:global([data-tome-block-wrapper]) > :where(*)`). Next's strict CSS-Module loader rejects pure-global selectors with "Selector ... is not pure (pure selectors must contain at least one local class or id)". Anchoring on `.grid` makes the selector impure-but-deterministic; semantics are unchanged because the rule is only meaningful inside a `.grid` ancestor anyway. Discovered during the Wabbit Phase A.3 grid-wrapper pilot (2026-04-28 audit) — without this fix, consumer sites couldn't `import tomeGrid from '@wabbit/tome-ui/grid'`.

  • **Anchor block-wrapper default selector on `.grid` (CSS-Modules pure-selector compliance).** `grid.module.css` had a pure-global selector at the block-wrapper default rule (`:global([data-tome-block-wrapper]) > :where(*)`). Next's strict CSS-Module loader rejects pure-global selectors with "Selector ... is not pure (pure selectors must contain at least one local class or id)". Anchoring on `.grid` makes the selector impure-but-deterministic; semantics are unchanged because the rule is only meaningful inside a `.grid` ancestor anyway. Discovered during the Wabbit Phase A.3 grid-wrapper pilot (2026-04-28 audit) — without this fix, consumer sites couldn't `import tomeGrid from '@wabbit/tome-ui/grid'`.
v0.4.1patch

**Add `--tome-type-size-*` aliases to bridge pack-renderer references to the platform's `--tome-text-*` scale.** Pack CSS Modules across `blocks-extras`, `blocks-marketing-starter`, `blocks-content-writer`, `blocks-agency-essentials`, `blocks-editorial-pack` reference 15 distinct `--tome-type-size-*` tokens (`micro`, `xxs`, `xs`, `sm`, `base`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl`, `5xl`, `xxl`, `xxxl`, `hero`). The platform only ships `--tome-text-*` (h1–h6, lg, body, sm, xs). Until now, font-size declarations in pack renderers fell through to the browser default (`font-size: medium`, ~16px) in any consumer that hadn't manually defined the family. Only Marquee and ImageMarquee carried inline fallbacks; ~140+ other references were bare `var(--tome-type-size-*)`. This adds the 15 aliases to `tokens.css`, mapping to the existing `--tome-text-*` clamp scale where the semantic intent matches. The 3 hero-marquee references with inline fallbacks are unaffected (their fallbacks remain authoritative for that oversized scale). Discovered during the Wabbit ↔ tome-blocks alignment audit (2026-04-28). No pack-side changes; aliases activate the existing CSS as authored.

  • **Add `--tome-type-size-*` aliases to bridge pack-renderer references to the platform's `--tome-text-*` scale.** Pack CSS Modules across `blocks-extras`, `blocks-marketing-starter`, `blocks-content-writer`, `blocks-agency-essentials`, `blocks-editorial-pack` reference 15 distinct `--tome-type-size-*` tokens (`micro`, `xxs`, `xs`, `sm`, `base`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl`, `5xl`, `xxl`, `xxxl`, `hero`). The platform only ships `--tome-text-*` (h1–h6, lg, body, sm, xs). Until now, font-size declarations in pack renderers fell through to the browser default (`font-size: medium`, ~16px) in any consumer that hadn't manually defined the family. Only Marquee and ImageMarquee carried inline fallbacks; ~140+ other references were bare `var(--tome-type-size-*)`. This adds the 15 aliases to `tokens.css`, mapping to the existing `--tome-text-*` clamp scale where the semantic intent matches. The 3 hero-marquee references with inline fallbacks are unaffected (their fallbacks remain authoritative for that oversized scale). Discovered during the Wabbit ↔ tome-blocks alignment audit (2026-04-28). No pack-side changes; aliases activate the existing CSS as authored.
v0.3.0minor

Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

  • Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

Motion

v0.5.2
v0.5.2patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.5.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.5.0minor

7981075: `RouteTransition` now server-renders its `@view-transition { navigation: auto; }` rule into `<head>` (a React 19 hoisted `<style>`) instead of injecting it from a layout effect after hydration. A cross-document view transition needs the opt-in in both documents when the new page is revealed, which happens before hydration. The effect-injected rule was always too late on the arriving page, so Chrome aborted every transition and threw an uncaught `InvalidStateError: Transition was aborted because of invalid state. ViewTransition opt-in disabled` on each full page load. Full page loads now actually cross-fade on sites that mount `RouteTransition`. The rule has no effect on client-router (same-document) navigations, and the docs no longer claim it does. `disabled` is decided per document: React keeps a hoisted style once inserted, so turning `disabled` on during a client navigation does not remove a rule an earlier route already added.

  • 7981075: `RouteTransition` now server-renders its `@view-transition { navigation: auto; }` rule into `<head>` (a React 19 hoisted `<style>`) instead of injecting it from a layout effect after hydration. A cross-document view transition needs the opt-in in both documents when the new page is revealed, which happens before hydration. The effect-injected rule was always too late on the arriving page, so Chrome aborted every transition and threw an uncaught `InvalidStateError: Transition was aborted because of invalid state. ViewTransition opt-in disabled` on each full page load. Full page loads now actually cross-fade on sites that mount `RouteTransition`. The rule has no effect on client-router (same-document) navigations, and the docs no longer claim it does. `disabled` is decided per document: React keeps a hoisted style once inserted, so turning `disabled` on during a client navigation does not remove a rule an earlier route already added.
v0.4.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.4.0minor

45f349a: **BREAKING:** `useDebouncedResize` is no longer exported from `@wabbit/tome-motion`; import it from `@wabbit/tome-blocks-core/utilities/useDebouncedResize`. **Migration:** replace `import { useDebouncedResize } from '@wabbit/tome-motion'` with `import { useDebouncedResize } from '@wabbit/tome-blocks-core/utilities/useDebouncedResize'`. The hook's signature and behavior are unchanged; only its import path moves. Add `@wabbit/tome-blocks-core` to your own dependencies if you do not already install it. **Deprecation window:** the motion re-export was marked `@deprecated` and announced for removal in 0.2.0. It stayed in place through the 0.2.x and 0.3.x lines, well past that announced window, and is removed now. The hook is a plain DOM helper with no motion dependency, so it lives with the block substrate whether or not motion is installed. With the re-export gone, `@wabbit/tome-motion` no longer depends on `@wabbit/tome-blocks-core`, so installing motion stops pulling in the block substrate.

  • 45f349a: **BREAKING:** `useDebouncedResize` is no longer exported from `@wabbit/tome-motion`; import it from `@wabbit/tome-blocks-core/utilities/useDebouncedResize`. **Migration:** replace `import { useDebouncedResize } from '@wabbit/tome-motion'` with `import { useDebouncedResize } from '@wabbit/tome-blocks-core/utilities/useDebouncedResize'`. The hook's signature and behavior are unchanged; only its import path moves. Add `@wabbit/tome-blocks-core` to your own dependencies if you do not already install it. **Deprecation window:** the motion re-export was marked `@deprecated` and announced for removal in 0.2.0. It stayed in place through the 0.2.x and 0.3.x lines, well past that announced window, and is removed now. The hook is a plain DOM helper with no motion dependency, so it lives with the block substrate whether or not motion is installed. With the re-export gone, `@wabbit/tome-motion` no longer depends on `@wabbit/tome-blocks-core`, so installing motion stops pulling in the block substrate.
v0.3.3patch

8af01f8: Tweens that run on mount no longer animate for users who prefer reduced motion. `MotionProvider` now reads `prefers-reduced-motion` synchronously on the client's first render instead of starting at `false`, so `useTween` calls that run on mount no longer animate for reduced-motion users. `useTween` also re-runs when the preference changes after mount.

  • 8af01f8: Tweens that run on mount no longer animate for users who prefer reduced motion. `MotionProvider` now reads `prefers-reduced-motion` synchronously on the client's first render instead of starting at `false`, so `useTween` calls that run on mount no longer animate for reduced-motion users. `useTween` also re-runs when the preference changes after mount.
  • Updated dependencies [1e481f4]
  • Updated dependencies [252ef2d]
  • Updated dependencies [cbbc37c] - @wabbit/tome-blocks-core@0.18.1
v0.3.2patch

Updated dependencies [c3468b0] - @wabbit/tome-blocks-core@0.18.0

  • Updated dependencies [c3468b0] - @wabbit/tome-blocks-core@0.18.0
v0.3.1patch

Updated dependencies [404d325] - @wabbit/tome-blocks-core@0.17.0

  • Updated dependencies [404d325] - @wabbit/tome-blocks-core@0.17.0
v0.3.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • 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.
  • 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.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0
v0.2.25patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9
v0.2.24patch

108195b: useTicker: make `enabled` actually able to STOP a running ticker. `enabled` could only ever prevent a ticker from starting. Flipping it false after mount did nothing, silently. useGSAP computes `deferCleanup = dependencies.length && !revertOnUpdate`, and deferred cleanup runs only on unmount. So a dependency change re-invoked the effect body without first tearing down the previous run: the `enabled === false` early return was reached, `ticker.remove(callback)` never was, and the callback registered on the first render kept firing forever — while the call site read as though it had been switched off. Found the hard way. The marquee viewport gate (blocks-extras 0.15.3) passed `enabled: inViewport`, typechecked, built, shipped, and changed nothing measurable: the off-screen marquee kept mutating at ~127/s in production because its ticker was registered once and never removed. `revertOnUpdate` is now set only when a consumer actually passes `enabled`, so anyone relying on the existing defer-to-unmount lifecycle is unaffected. At time of writing the only `useTicker` consumers in the monorepo are Marquee and ImageMarquee, both of which pass `enabled`. Covered by `src/test/useTicker.test.tsx` — registration, teardown on flip, re-registration on flip back, and non-interference for consumers without `enabled`. The teardown test was verified to fail when `revertOnUpdate` is removed, rather than merely to pass as written.

  • 108195b: useTicker: make `enabled` actually able to STOP a running ticker. `enabled` could only ever prevent a ticker from starting. Flipping it false after mount did nothing, silently. useGSAP computes `deferCleanup = dependencies.length && !revertOnUpdate`, and deferred cleanup runs only on unmount. So a dependency change re-invoked the effect body without first tearing down the previous run: the `enabled === false` early return was reached, `ticker.remove(callback)` never was, and the callback registered on the first render kept firing forever — while the call site read as though it had been switched off. Found the hard way. The marquee viewport gate (blocks-extras 0.15.3) passed `enabled: inViewport`, typechecked, built, shipped, and changed nothing measurable: the off-screen marquee kept mutating at ~127/s in production because its ticker was registered once and never removed. `revertOnUpdate` is now set only when a consumer actually passes `enabled`, so anyone relying on the existing defer-to-unmount lifecycle is unaffected. At time of writing the only `useTicker` consumers in the monorepo are Marquee and ImageMarquee, both of which pass `enabled`. Covered by `src/test/useTicker.test.tsx` — registration, teardown on flip, re-registration on flip back, and non-interference for consumers without `enabled`. The teardown test was verified to fail when `revertOnUpdate` is removed, rather than merely to pass as written.
v0.2.23patch

dfccbc1: LenisProvider: skip the per-frame scroll write while Lenis is at rest. `lenis.raf()` was ticked unconditionally from GSAP's ticker. It writes scroll state on every frame it is handed — at rest or not — and each write forces a style recalculation, so an open tab paid a recalc per frame for as long as it stayed open. Measured on wabbit.com: 144 recalcs/s and ~8% of a core on a page nobody was touching, indefinitely. The same page with Lenis absent (`/block-review`, a native-scroll route) idles at 0 recalcs and ~1.6%. This is what triggers Chrome's "this tab is slowing your browser" intervention. The ticker now returns early when `isScrolling` is falsy. That cannot deadlock: every entry point sets the flag synchronously inside the input handler, before the next tick — wheel/touch via `onVirtualScroll` -> `scrollTo` ('smooth'), scrollbar/keyboard via `onNativeScroll` ('native'), and programmatic `scrollTo`. The idle branch still advances `lenis.time`, because Lenis derives `deltaTime = time - (this.time || time)`; leaving the clock stale would hand the first resumed frame a delta of the entire idle span, completing the ease instantly and snapping the scroll instead of easing it. No API change. Scroll feel is unchanged — only the at-rest cost goes away.

  • dfccbc1: LenisProvider: skip the per-frame scroll write while Lenis is at rest. `lenis.raf()` was ticked unconditionally from GSAP's ticker. It writes scroll state on every frame it is handed — at rest or not — and each write forces a style recalculation, so an open tab paid a recalc per frame for as long as it stayed open. Measured on wabbit.com: 144 recalcs/s and ~8% of a core on a page nobody was touching, indefinitely. The same page with Lenis absent (`/block-review`, a native-scroll route) idles at 0 recalcs and ~1.6%. This is what triggers Chrome's "this tab is slowing your browser" intervention. The ticker now returns early when `isScrolling` is falsy. That cannot deadlock: every entry point sets the flag synchronously inside the input handler, before the next tick — wheel/touch via `onVirtualScroll` -> `scrollTo` ('smooth'), scrollbar/keyboard via `onNativeScroll` ('native'), and programmatic `scrollTo`. The idle branch still advances `lenis.time`, because Lenis derives `deltaTime = time - (this.time || time)`; leaving the clock stale would hand the first resumed frame a delta of the entire idle span, completing the ease instantly and snapping the scroll instead of easing it. No API change. Scroll feel is unchanged — only the at-rest cost goes away.
v0.2.22patch

Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0

  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0
v0.2.21patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0
v0.2.20patch

Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0

  • Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0
v0.2.19patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2
v0.2.18patch

Updated dependencies [26dfa07]

  • Updated dependencies [26dfa07]
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-core@1.4.0
v0.2.17patch

Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-blocks-core@0.10.0
v0.2.16patch

Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-blocks-core@0.10.0
v0.2.15patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0
v0.2.14patch

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.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-core@1.2.1
v0.2.13patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2
v0.2.12patch

Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0

  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0
v0.2.11patch

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

  • Updated dependencies [249b670] - @wabbit/tome-blocks-core@0.8.0
v0.2.10patch

Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0

  • Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0
v0.2.9patch

Updated dependencies [a9801fe]

  • Updated dependencies [a9801fe]
  • Updated dependencies [baf401e]
  • Updated dependencies [4b2f368] - @wabbit/tome-core@1.1.0 - @wabbit/tome-blocks-core@0.6.2
v0.2.8patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-blocks-core@0.5.9

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-blocks-core@0.5.9
v0.2.7patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11 - @wabbit/tome-blocks-core@0.5.7
v0.2.4patch

**Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.

  • **Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.
  • Updated dependencies - @wabbit/tome-blocks-core@0.4.2
v0.2.3patch

Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0
v0.2.0minor

Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

  • Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.
  • Updated dependencies
  • Updated dependencies [f2202cd] - @wabbit/tome-core@0.2.0 - @wabbit/tome-blocks-core@0.3.0

Blocks Core

v0.49.4
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.49.3patch

27911be: Terminal Steps and Proof Ledger now land on the right gallery shelves. Terminal Steps carried a `code` tag that put it among the primitives, beside Spacer and Text Block, and it now sits with content. Proof Ledger matched no shelf and fell into a category of its own, and it now sits with data. Nothing changes on a page: tags only affect how a gallery groups the blocks.

  • 27911be: Terminal Steps and Proof Ledger now land on the right gallery shelves. Terminal Steps carried a `code` tag that put it among the primitives, beside Spacer and Text Block, and it now sits with content. Proof Ledger matched no shelf and fell into a category of its own, and it now sits with data. Nothing changes on a page: tags only affect how a gallery groups the blocks.
v0.49.0patch

The block catalog and block type slugs now list the six new Marketing Starter blocks and the new call to action and section header variants. `stop-viewer`, `switch-tally`, `proof-ledger`, `terminal-steps`, `layer-stack` and `term-rows` get catalog entries and slug pairs, so `data-block-type` carries the catalog slug and a theme can target them. The `cta` entry lists `command-close`, and the `section-header` entry lists `transcript-stage` and `product-split`.

  • The block catalog and block type slugs now list the six new Marketing Starter blocks and the new call to action and section header variants. `stop-viewer`, `switch-tally`, `proof-ledger`, `terminal-steps`, `layer-stack` and `term-rows` get catalog entries and slug pairs, so `data-block-type` carries the catalog slug and a theme can target them. The `cta` entry lists `command-close`, and the `section-header` entry lists `transcript-stage` and `product-split`.
v0.48.0patch

3954ae9: A page can now carry a breadcrumb trail, an urgent or important notice, and prose set beside its review line and a callout, all from blocks an editor composes. The new `breadcrumb` block is a labeled navigation landmark holding an ordered list of links that ends in the current page (`aria-current="page"`); labels wrap at any width, every link is at least 44px tall, an unsafe link shows as text, and no structured data is emitted. The `post-notice` block gains an optional `tone` (standard, important, urgent) and a `headingLevel`: a toned notice gets a mark before its heading and a heavier edge, never color alone, it stays a static note (no alert role), and an untoned notice renders byte for byte as before. The practice pack's reviewed line is now nestable, so it can sit in a layout grid; build the reading layout with a layout grid in its feature-and-sidebar arrangement holding a text block, the reviewed line and a notice. The block catalog lists the new block.

  • 3954ae9: A page can now carry a breadcrumb trail, an urgent or important notice, and prose set beside its review line and a callout, all from blocks an editor composes. The new `breadcrumb` block is a labeled navigation landmark holding an ordered list of links that ends in the current page (`aria-current="page"`); labels wrap at any width, every link is at least 44px tall, an unsafe link shows as text, and no structured data is emitted. The `post-notice` block gains an optional `tone` (standard, important, urgent) and a `headingLevel`: a toned notice gets a mark before its heading and a heavier edge, never color alone, it stays a static note (no alert role), and an untoned notice renders byte for byte as before. The practice pack's reviewed line is now nestable, so it can sit in a layout grid; build the reading layout with a layout grid in its feature-and-sidebar arrangement holding a text block, the reviewed line and a notice. The block catalog lists the new block.
v0.47.1patch

The block catalog and block type slugs now list the practice pack's thirteen blocks.

  • The block catalog and block type slugs now list the practice pack's thirteen blocks.
v0.46.1patch

0517734: The block catalog lists the two new `local-locations` and `local-desk-strip` blocks in the local pack.

  • 0517734: The block catalog lists the two new `local-locations` and `local-desk-strip` blocks in the local pack.
v0.46.0minor

ba8c8f6: RenderBlocks can map a renamed block back to its catalog slug, so themes still find it. `RenderBlocks` gains an optional `blockTypeSlugs` prop (stored type to catalog slug), consulted before the generated `BLOCK_TYPE_SLUGS` table when writing the per-block wrapper's `data-block-type`, and `@wabbit/tome-blocks-core/slugOverrides` gains `blockTypeSlugsForOverrides(overrides)`, which builds that map from the same overrides passed to `applyBlockSlugOverrides` and `remapRendererSlugs`. A site that renames a colliding pack block (agency-essentials `gallery` stored as `agency-gallery` beside a native Gallery block) now keeps `data-block-type="gallery"`, so theme treatments keyed on the catalog slug reach it with no wrapper element. `catalogSlugForBlockType` takes the same optional map as a second argument and moves to `render/catalogSlug.ts` (still exported from `RenderBlocks` and the barrels). Without the prop every wrapper renders exactly as before (a golden of the default markup, cut before the change, pins it).

  • ba8c8f6: RenderBlocks can map a renamed block back to its catalog slug, so themes still find it. `RenderBlocks` gains an optional `blockTypeSlugs` prop (stored type to catalog slug), consulted before the generated `BLOCK_TYPE_SLUGS` table when writing the per-block wrapper's `data-block-type`, and `@wabbit/tome-blocks-core/slugOverrides` gains `blockTypeSlugsForOverrides(overrides)`, which builds that map from the same overrides passed to `applyBlockSlugOverrides` and `remapRendererSlugs`. A site that renames a colliding pack block (agency-essentials `gallery` stored as `agency-gallery` beside a native Gallery block) now keeps `data-block-type="gallery"`, so theme treatments keyed on the catalog slug reach it with no wrapper element. `catalogSlugForBlockType` takes the same optional map as a second argument and moves to `render/catalogSlug.ts` (still exported from `RenderBlocks` and the barrels). Without the prop every wrapper renders exactly as before (a golden of the default markup, cut before the change, pins it).
v0.45.0minor

a6eaeb2: `BlockStyles` takes `dedupe={false}` so every instance emits its sheet, for a block rendered ahead and chosen on the client. Banner uses it: a layout that renders one alert per tenant and lets the header show the current one no longer gets an unstyled alert when another tenant's alert rendered first. marketing-starter now needs blocks-core 0.45.0 or later.

  • a6eaeb2: `BlockStyles` takes `dedupe={false}` so every instance emits its sheet, for a block rendered ahead and chosen on the client. Banner uses it: a layout that renders one alert per tenant and lets the header show the current one no longer gets an unstyled alert when another tenant's alert rendered first. marketing-starter now needs blocks-core 0.45.0 or later.
v0.44.0patch

d0f11bc: `MediaProps` gains an optional `fetchPriority` (`high`, `low` or `auto`) for the LCP image. The built-in default media adapter renders it as `fetchpriority` on the `<img>` when set, and adds nothing when it is not, so every existing image renders byte for byte as before. An adapter built on `next/image` should pass it through explicitly: next/image 16 does not infer `fetchPriority` from `priority`. The `high-impact-hero` `venue` variant is the first caller.

  • d0f11bc: `MediaProps` gains an optional `fetchPriority` (`high`, `low` or `auto`) for the LCP image. The built-in default media adapter renders it as `fetchpriority` on the `<img>` when set, and adds nothing when it is not, so every existing image renders byte for byte as before. An adapter built on `next/image` should pass it through explicitly: next/image 16 does not infer `fetchPriority` from `priority`. The `high-impact-hero` `venue` variant is the first caller.
v0.43.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.42.3patch

8e56ff7: `<BlockStyles>` emits each block stylesheet once per server request. React dedupes the hoisted `<style>` in the DOM but not in the RSC payload, so a block repeated on a page re-serialized its whole sheet each time; a wabbit.com essay with 22 sections shipped 3.74 MB of HTML (now 0.78 MB). Client components are unaffected.

  • 8e56ff7: `<BlockStyles>` emits each block stylesheet once per server request. React dedupes the hoisted `<style>` in the DOM but not in the RSC payload, so a block repeated on a page re-serialized its whole sheet each time; a wabbit.com essay with 22 sections shipped 3.74 MB of HTML (now 0.78 MB). Client components are unaffected.
v0.42.0minor

39cb8ef: New `@wabbit/tome-blocks-core/usage/node` subpath: `loadInstalledBlockMeta(root)` finds the block packs installed in a checkout and returns their block metadata, ready for `buildUsageManifest`. Additive. Node-only; it never throws, and a pack that cannot load is skipped with a warning on stderr.

  • 39cb8ef: New `@wabbit/tome-blocks-core/usage/node` subpath: `loadInstalledBlockMeta(root)` finds the block packs installed in a checkout and returns their block metadata, ready for `buildUsageManifest`. Additive. Node-only; it never throws, and a pack that cannot load is skipped with a warning on stderr.
v0.39.2patch

1f63019: The block render registry now keeps a separate set of renderers for the server-components graph and for the SSR/client graph, so a pack renderer that wraps a client component can no longer be rendered from the wrong graph. - A renderer registered from the server-components graph holds a client reference where it renders a `'use client'` component. When the SSR graph looked it up through `getRenderer`, `resolveRenderer` or `RenderBlock` and rendered it, the page threw "Attempted to call TestimonialClient() from the server but TestimonialClient is on the client". - Each graph now registers into, and reads from, its own store (the same rule the block-adapter registry already follows), so a lookup returns only a renderer registered from the graph it runs in. Within one graph the store is still shared across duplicate module instances. - A site that registers renderers only from a `'use client'` module and renders them with a server-component `RenderBlock` must also call the pack's `registerRenderers()` from a module the server graph imports; the missing-renderer warning in `RenderBlock` names this fix.

  • 1f63019: The block render registry now keeps a separate set of renderers for the server-components graph and for the SSR/client graph, so a pack renderer that wraps a client component can no longer be rendered from the wrong graph. - A renderer registered from the server-components graph holds a client reference where it renders a `'use client'` component. When the SSR graph looked it up through `getRenderer`, `resolveRenderer` or `RenderBlock` and rendered it, the page threw "Attempted to call TestimonialClient() from the server but TestimonialClient is on the client". - Each graph now registers into, and reads from, its own store (the same rule the block-adapter registry already follows), so a lookup returns only a renderer registered from the graph it runs in. Within one graph the store is still shared across duplicate module instances. - A site that registers renderers only from a `'use client'` module and renders them with a server-component `RenderBlock` must also call the pack's `registerRenderers()` from a module the server graph imports; the missing-renderer warning in `RenderBlock` names this fix.
v0.38.0minor

06221cd: New `AddToBag` adapter: a pack renders an add-to-bag control with `resolveAddToBag()`, and a commerce layer can register the real control. It sits beside the link, media and rich-text adapters with the same mechanics: `registerBlockAdapters({ addToBag })`, `AddToBagAdapterProvider`, the module-graph stamp that keeps a server-graph registration out of the client graph, and a `'use client'` `AddToBagContextFallback`. The product is a reference, `{ slug, id?, href?, title?, hasVariants?, purchasable? }`, so a block that only knows a catalog slug can still render it. The props also take `variantSku`, `label`, `priceLabel`, `size` (`small` | `default`) and `mode` (`pill` | `stepper` | `restock`). With nothing registered the default is a working link to `product.href` (`NOOP_ADD_TO_BAG_ADAPTER`, `isAddToBagActive: false`), carrying `data-add`, `data-add-state="idle"` and the price in `span[data-add-price]`. With no `href` it renders nothing and never warns. Existing adapters and their output are unchanged. `RenderBlocks` now writes the block wrapper's inline column as `grid-column: var(--tome-block-column, <placement>)` instead of `grid-column: <placement>`. With the property unset the computed column is exactly the placement, so no page moves; a page template or theme sets `--tome-block-column` on a wrapper, per breakpoint and without `!important`, to move one block. The only change to a stored render is that style string (the snapshot and golden tests list it as an explicit allowance); `data-block-placement` is unchanged, and a stylesheet must still never match the style text. `AddToBagSize` gains `'compact'` (restock mode: a one-line link or button instead of the form, for a tile whose plate is small or shows a second frame). An adapter that does not know it treats it as `small`; nothing existing passes it.

  • 06221cd: New `AddToBag` adapter: a pack renders an add-to-bag control with `resolveAddToBag()`, and a commerce layer can register the real control. It sits beside the link, media and rich-text adapters with the same mechanics: `registerBlockAdapters({ addToBag })`, `AddToBagAdapterProvider`, the module-graph stamp that keeps a server-graph registration out of the client graph, and a `'use client'` `AddToBagContextFallback`. The product is a reference, `{ slug, id?, href?, title?, hasVariants?, purchasable? }`, so a block that only knows a catalog slug can still render it. The props also take `variantSku`, `label`, `priceLabel`, `size` (`small` | `default`) and `mode` (`pill` | `stepper` | `restock`). With nothing registered the default is a working link to `product.href` (`NOOP_ADD_TO_BAG_ADAPTER`, `isAddToBagActive: false`), carrying `data-add`, `data-add-state="idle"` and the price in `span[data-add-price]`. With no `href` it renders nothing and never warns. Existing adapters and their output are unchanged. `RenderBlocks` now writes the block wrapper's inline column as `grid-column: var(--tome-block-column, <placement>)` instead of `grid-column: <placement>`. With the property unset the computed column is exactly the placement, so no page moves; a page template or theme sets `--tome-block-column` on a wrapper, per breakpoint and without `!important`, to move one block. The only change to a stored render is that style string (the snapshot and golden tests list it as an explicit allowance); `data-block-placement` is unchanged, and a stylesheet must still never match the style text. `AddToBagSize` gains `'compact'` (restock mode: a one-line link or button instead of the form, for a tile whose plate is small or shows a second frame). An adapter that does not know it treats it as `small`; nothing existing passes it.
v0.37.4patch

116e8ce: Docs manifests keep each changelog entry's paragraphs, lists and code blocks instead of joining them into one line.

  • 116e8ce: Docs manifests keep each changelog entry's paragraphs, lists and code blocks instead of joining them into one line.
v0.37.0minor

d69161c: `TomeLink` and `resolveLink()` now announce links that open a new tab: a `target="_blank"` anchor gains `data-new-tab` and a visually hidden " (opens in a new tab)". The hint is added ahead of the link adapter, so it reaches the plain anchor and any registered `Link` alike. A link with an `aria-label` gets the phrase appended to the label instead, and links without `target="_blank"` render exactly as before. Every pack block that routes its links through `TomeLink`, including the hero link lists, picks it up with no change of its own.

  • d69161c: `TomeLink` and `resolveLink()` now announce links that open a new tab: a `target="_blank"` anchor gains `data-new-tab` and a visually hidden " (opens in a new tab)". The hint is added ahead of the link adapter, so it reaches the plain anchor and any registered `Link` alike. A link with an `aria-label` gets the phrase appended to the label instead, and links without `target="_blank"` render exactly as before. Every pack block that routes its links through `TomeLink`, including the hero link lists, picks it up with no change of its own.
v0.35.0minor

f4a08ca: `RenderBlocks` names each block wrapper's placement in a new `data-block-placement` attribute, so stylesheets no longer have to read the wrapper's inline style text. - The value is `full` (`1 / -1`, the default, or `full-start / full-end`), `content` (`2 / -2` or `content-start / content-end`), another page-grid area's name (`reading-start / reading-end` is `reading`), or `custom` for any other span. The inline `style` is unchanged; the attribute is the only addition to the markup. - Why: a selector such as `[style^='grid-column:1 / -1;']` depends on how the browser last serialised the attribute. Once any script writes to the element's inline style (tome-ui's scroll reveal sets a custom property there), WebKit rewrites the whole attribute as `grid-column-start: 1; grid-column-end: -1; …`, and the match fails in Safari only. - New exports from `@wabbit/tome-blocks-core/render` (and the root entry): `blockPlacementFor(columnSpan)`, `BLOCK_PLACEMENT_ATTR` and the `BlockPlacement` type, for a site that renders its own wrapper and wants the same attribute.

  • f4a08ca: `RenderBlocks` names each block wrapper's placement in a new `data-block-placement` attribute, so stylesheets no longer have to read the wrapper's inline style text. - The value is `full` (`1 / -1`, the default, or `full-start / full-end`), `content` (`2 / -2` or `content-start / content-end`), another page-grid area's name (`reading-start / reading-end` is `reading`), or `custom` for any other span. The inline `style` is unchanged; the attribute is the only addition to the markup. - Why: a selector such as `[style^='grid-column:1 / -1;']` depends on how the browser last serialised the attribute. Once any script writes to the element's inline style (tome-ui's scroll reveal sets a custom property there), WebKit rewrites the whole attribute as `grid-column-start: 1; grid-column-end: -1; …`, and the match fails in Safari only. - New exports from `@wabbit/tome-blocks-core/render` (and the root entry): `blockPlacementFor(columnSpan)`, `BLOCK_PLACEMENT_ATTR` and the `BlockPlacement` type, for a site that renders its own wrapper and wants the same attribute.
v0.34.0minor

e3aadfe: Theme hooks on links reach the DOM. - `@wabbit/tome-blocks-core`: `LinkProps` declares `data-*` and `aria-*` attributes, and the default anchor (`NoopLink`, used by `TomeLink` / `resolveLink` when no link adapter is registered) forwards them. It forwarded only `href`, `className`, `style`, `id`, `target` and `rel`, so every hook a pack put on a link (`data-section-label-link`, `data-panel-cta` and others) was dropped. Links without such attributes render exactly as before, and props that are neither `data-*` nor `aria-*` still stay off the anchor. A registered link adapter receives the attributes too: spread the rest of your `Link`'s props onto its anchor to keep them. - `@wabbit/tome-blocks-dossier-pack`: each `zone-directory` cell (the link anchor) carries `data-zone-cell`.

  • e3aadfe: Theme hooks on links reach the DOM. - `@wabbit/tome-blocks-core`: `LinkProps` declares `data-*` and `aria-*` attributes, and the default anchor (`NoopLink`, used by `TomeLink` / `resolveLink` when no link adapter is registered) forwards them. It forwarded only `href`, `className`, `style`, `id`, `target` and `rel`, so every hook a pack put on a link (`data-section-label-link`, `data-panel-cta` and others) was dropped. Links without such attributes render exactly as before, and props that are neither `data-*` nor `aria-*` still stay off the anchor. A registered link adapter receives the attributes too: spread the rest of your `Link`'s props onto its anchor to keep them. - `@wabbit/tome-blocks-dossier-pack`: each `zone-directory` cell (the link anchor) carries `data-zone-cell`.
v0.31.0patch

fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.

  • fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.
v0.30.0minor

9fbffc6: **BREAKING:** `RenderBlocks` sets `data-block-type` to the catalog slug (`high-impact-hero`), not the stored `blockType` (`highImpactHero`), which moves to `data-block-stored-type`. **Migration:** a stylesheet that targets a camelCase stored value, such as `[data-block-type="highImpactHero"]`, should target the catalog slug from `BLOCK_CATALOG` instead (`[data-block-type="high-impact-hero"]`), or move to `[data-block-stored-type="highImpactHero"]`. Rules that already use the documented catalog slug start matching real CMS rows. Resolution goes through the new generated `BLOCK_TYPE_SLUGS` map, which also covers the blocks whose stored type differs by name (`formBlock` → `form`, `mediaBlock` → `media`, `cardBlock` → `card`, `checkList` → `checklist`, `customblock` → `custom`, `text` → `text-block`). A kebab-stored row resolves too. A block outside the catalog, or one renamed with `applyBlockSlugOverrides`, keeps its stored value. `catalogSlugForBlockType(blockType)` exposes the same lookup.

  • 9fbffc6: **BREAKING:** `RenderBlocks` sets `data-block-type` to the catalog slug (`high-impact-hero`), not the stored `blockType` (`highImpactHero`), which moves to `data-block-stored-type`. **Migration:** a stylesheet that targets a camelCase stored value, such as `[data-block-type="highImpactHero"]`, should target the catalog slug from `BLOCK_CATALOG` instead (`[data-block-type="high-impact-hero"]`), or move to `[data-block-stored-type="highImpactHero"]`. Rules that already use the documented catalog slug start matching real CMS rows. Resolution goes through the new generated `BLOCK_TYPE_SLUGS` map, which also covers the blocks whose stored type differs by name (`formBlock` → `form`, `mediaBlock` → `media`, `cardBlock` → `card`, `checkList` → `checklist`, `customblock` → `custom`, `text` → `text-block`). A kebab-stored row resolves too. A block outside the catalog, or one renamed with `applyBlockSlugOverrides`, keeps its stored value. `catalogSlugForBlockType(blockType)` exposes the same lookup.
v0.29.0patch

115bcfb: The generated block catalog now lists the dossier pack's `dated-ledger` block (`columns`, `rows`) and Phase Ledger's `default` and `rail` variants. Catalog text regenerated from the updated dossier pack metadata.

  • 115bcfb: The generated block catalog now lists the dossier pack's `dated-ledger` block (`columns`, `rows`) and Phase Ledger's `default` and `rail` variants. Catalog text regenerated from the updated dossier pack metadata.
  • a6802f8: The generated block catalog now lists the High Impact Hero's `quick-ask` variant and the Contact block's `estimate` variant. Catalog text regenerated from the updated marketing-starter and agency-essentials metadata.
v0.28.6patch

9d30f92: `MediaProps` gains optional `fill` and `imgClassName`, so a block can ask a media adapter for an image that fills a box it sizes itself. The built-in media adapters render one `<img>` and now put `imgClassName` on it beside `className`; they ignore `fill`, since the block styles `className` to fill the box. An adapter that wraps its image (a `<div>` or `<picture>` taking `className`) should render the image in fill mode over the wrapper, put `imgClassName` on the `<img>` and pass `sizes` through. Nothing changes for a call that sets neither prop.

  • 9d30f92: `MediaProps` gains optional `fill` and `imgClassName`, so a block can ask a media adapter for an image that fills a box it sizes itself. The built-in media adapters render one `<img>` and now put `imgClassName` on it beside `className`; they ignore `fill`, since the block styles `className` to fill the box. An adapter that wraps its image (a `<div>` or `<picture>` taking `className`) should render the image in fill mode over the wrapper, put `imgClassName` on the `<img>` and pass `sizes` through. Nothing changes for a call that sets neither prop.
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.28.3patch

5ea0301: The generated block catalog now lists the Term Ledger's `spec-measure` variant. Catalog text regenerated from the updated dossier pack metadata.

  • 5ea0301: The generated block catalog now lists the Term Ledger's `spec-measure` variant. Catalog text regenerated from the updated dossier pack metadata.
v0.28.1patch

28cfbd7: The generated block catalog now describes the campaign count block as reading live figures from the site's campaign source. Catalog text regenerated from the updated campaign pack metadata.

  • 28cfbd7: The generated block catalog now describes the campaign count block as reading live figures from the site's campaign source. Catalog text regenerated from the updated campaign pack metadata.
v0.28.0patch

483e0a1: The generated block catalog now describes the proposal plans block with generic service-level names. Catalog text regenerated from the updated proposal pack metadata.

  • 483e0a1: The generated block catalog now describes the proposal plans block with generic service-level names. Catalog text regenerated from the updated proposal pack metadata.
  • 6301bf1: The block catalog entry for the custom hero now describes it in plain terms and states the correct number of design versions.
v0.27.0minor

c14a133: `styles.css` now brings the page grid too, and `RenderBlocks` uses it by default, so a site no longer wires a grid to render blocks. `@wabbit/tome-blocks-core/styles.css` imports tome-ui's new `grid.global.css` beside the design tokens, and `RenderBlocks` gives its container the global `tome-grid` class when no `gridClassName` is passed (`DEFAULT_GRID_CLASS` is exported). Passing `gridClassName` works as before. With the packs now shipping their stylesheets precompiled, that one CSS line is the whole site setup: no next.config change. `withTomeBlockStyles` is still how a site compiles block stylesheets of its own. **BREAKING:** requires `@wabbit/tome-ui` 0.16.0 or later (the first with `grid.global.css`); npm and pnpm install the peer automatically. A `RenderBlocks` call with neither `gridClassName` nor `disableContainer` now renders its container with the `tome-grid` class, so on a site that loads `styles.css` those blocks sit on the page grid instead of in a plain block container. - `styles.css` also sets `box-sizing: border-box` inside block wrappers (`[data-tome-block-wrapper]`), at zero specificity. Block CSS is written for border-box, so on a site without a global reset a full-width padded block overflowed the page; the rule doesn't reach anything outside blocks. - `compileBlockStylesheet` (`./block-styles/loader`) takes an optional `id`, used for the class-name and href hashes instead of the file's path, so a stylesheet compiled at a package's build gets the same names wherever the package is installed.

  • c14a133: `styles.css` now brings the page grid too, and `RenderBlocks` uses it by default, so a site no longer wires a grid to render blocks. `@wabbit/tome-blocks-core/styles.css` imports tome-ui's new `grid.global.css` beside the design tokens, and `RenderBlocks` gives its container the global `tome-grid` class when no `gridClassName` is passed (`DEFAULT_GRID_CLASS` is exported). Passing `gridClassName` works as before. With the packs now shipping their stylesheets precompiled, that one CSS line is the whole site setup: no next.config change. `withTomeBlockStyles` is still how a site compiles block stylesheets of its own. **BREAKING:** requires `@wabbit/tome-ui` 0.16.0 or later (the first with `grid.global.css`); npm and pnpm install the peer automatically. A `RenderBlocks` call with neither `gridClassName` nor `disableContainer` now renders its container with the `tome-grid` class, so on a site that loads `styles.css` those blocks sit on the page grid instead of in a plain block container. - `styles.css` also sets `box-sizing: border-box` inside block wrappers (`[data-tome-block-wrapper]`), at zero specificity. Block CSS is written for border-box, so on a site without a global reset a full-width padded block overflowed the page; the rule doesn't reach anything outside blocks. - `compileBlockStylesheet` (`./block-styles/loader`) takes an optional `id`, used for the class-name and href hashes instead of the file's path, so a stylesheet compiled at a package's build gets the same names wherever the package is installed.
v0.25.1patch

The block-stylesheet loader's relative-`url()` check no longer looks inside quoted values. A data-URI SVG that references its own filter (`url("data:image/svg+xml,…filter='url(%23n)'…")`) was rejected as a relative URL because the check matched the inner `url(%23n)`. Each `url()` value is now read whole, so text inside a quoted data URI is ignored, and a genuinely relative `url(./x.png)` is still rejected, with the offending value named in the error.

  • The block-stylesheet loader's relative-`url()` check no longer looks inside quoted values. A data-URI SVG that references its own filter (`url("data:image/svg+xml,…filter='url(%23n)'…")`) was rejected as a relative URL because the check matched the inner `url(%23n)`. Each `url()` value is now read whole, so text inside a quoted data URI is ignored, and a genuinely relative `url(./x.png)` is still rejected, with the offending value named in the error.
v0.22.0patch

`<BlockStyles>` now works inside client components. The browser bundle of a `.tome-css` / `.tome-scss` stylesheet carries its compiled sheet too, as the server bundles do. Before, it carried class names only, so a block style rendered from a client component (a site's client footer rendering a pack's Marquee, for example) failed hydration in dev and dropped its styles. In production it would leave the block unstyled when that client component first mounted after client-side navigation. The cost is the CSS text in client JS, only for stylesheets that client code imports; stylesheets imported only by server components are unchanged. The loader no longer needs a browser/server split, so `withTomeBlockStyles` registers one rule per bundler.

  • `<BlockStyles>` now works inside client components. The browser bundle of a `.tome-css` / `.tome-scss` stylesheet carries its compiled sheet too, as the server bundles do. Before, it carried class names only, so a block style rendered from a client component (a site's client footer rendering a pack's Marquee, for example) failed hydration in dev and dropped its styles. In production it would leave the block unstyled when that client component first mounted after client-side navigation. The cost is the CSS text in client JS, only for stylesheets that client code imports; stylesheets imported only by server components are unchanged. The loader no longer needs a browser/server split, so `withTomeBlockStyles` registers one rule per bundler.
v0.21.1patch

Per-block stylesheet fixes from the first consumer canary (tome-starter): - `withTomeBlockStyles` now accepts and returns the caller's own config type. A typed `next.config.ts` (`const nextConfig: NextConfig`) failed to type-check, because Next's `NextConfig` has no index signatures. - Calling `withTomeBlockStyles(nextConfig)` with no options failed the Turbopack build ("does not have serializable options"): unset options were passed as `undefined`. Only defined options are passed now. - In `next dev`, `<BlockStyles>` renders an inline `<style>` instead of a hoisted one. Turbopack dev gives each CSS chunk its own precedence group, so a hoisted block style could land between Next's chunks and lose ties it wins in production (seen as a component's padding overriding a block's). The inline style keeps the production cascade and updates live on edit, replacing the dev stale-style cleanup. Production output is unchanged.

  • Per-block stylesheet fixes from the first consumer canary (tome-starter): - `withTomeBlockStyles` now accepts and returns the caller's own config type. A typed `next.config.ts` (`const nextConfig: NextConfig`) failed to type-check, because Next's `NextConfig` has no index signatures. - Calling `withTomeBlockStyles(nextConfig)` with no options failed the Turbopack build ("does not have serializable options"): unset options were passed as `undefined`. Only defined options are passed now. - In `next dev`, `<BlockStyles>` renders an inline `<style>` instead of a hoisted one. Turbopack dev gives each CSS chunk its own precedence group, so a hoisted block style could land between Next's chunks and lose ties it wins in production (seen as a component's padding overriding a block's). The inline style keeps the production cascade and updates live on edit, replacing the dev stale-style cleanup. Production output is unchanged.
v0.21.0minor

Per-block stylesheets. A block stylesheet named `*.tome-scss` (Sass) or `*.tome-css` (plain CSS) is compiled per block and inlined only on pages that render the block, instead of shipping in every page's CSS bundle. New exports: `./next` (`withTomeBlockStyles(nextConfig)`, which registers the loader for webpack and Turbopack), `./block-styles` (`<BlockStyles sheet={styles} />`, a hoisted, deduped React 19 `<style precedence="tome-block">`), `./block-styles/loader` and `./tome-stylesheets` (module type declarations). Additive: nothing changes for existing `.module.css` blocks; packs adopt it as they convert. See the README's "Per-block stylesheets" section for site setup.

  • Per-block stylesheets. A block stylesheet named `*.tome-scss` (Sass) or `*.tome-css` (plain CSS) is compiled per block and inlined only on pages that render the block, instead of shipping in every page's CSS bundle. New exports: `./next` (`withTomeBlockStyles(nextConfig)`, which registers the loader for webpack and Turbopack), `./block-styles` (`<BlockStyles sheet={styles} />`, a hoisted, deduped React 19 `<style precedence="tome-block">`), `./block-styles/loader` and `./tome-stylesheets` (module type declarations). Additive: nothing changes for existing `.module.css` blocks; packs adopt it as they convert. See the README's "Per-block stylesheets" section for site setup.
v0.20.0minor

c94d653: `RenderBlocks` now emits `data-block-type="<blockType>"` on each per-block wrapper, a public hook themes use to restyle a specific block. The canonical theme selector is descendant: `[data-tome-theme="x"] [data-block-type="<slug>"] [data-block-variant="<v>"]` (type on the wrapper, variant on the block root). The attribute is additive; it changes rendering only where a stylesheet already targets it. `RenderBlock` (single-block, registry dispatch) renders no wrapper and is unchanged.

  • c94d653: `RenderBlocks` now emits `data-block-type="<blockType>"` on each per-block wrapper, a public hook themes use to restyle a specific block. The canonical theme selector is descendant: `[data-tome-theme="x"] [data-block-type="<slug>"] [data-block-variant="<v>"]` (type on the wrapper, variant on the block root). The attribute is additive; it changes rendering only where a stylesheet already targets it. `RenderBlock` (single-block, registry dispatch) renders no wrapper and is unchanged.
v0.18.3patch

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behavior change, and the published `dist/` is identical.
  • 786853e: `resolveThumbnail('pricing-plans', …)` now returns the pricing block's SVG thumbnails, which were keyed under the block's old `pricing` slug. The thumbnail ingester now takes a block's slug from its own `meta.ts` when the directory name differs, and the generated index was regenerated. `BLOCK_CATALOG` was regenerated with the new slug and updated block and variant descriptions.
v0.18.1patch

1e481f4: `BLOCK_CATALOG` regenerated so the `banner`, `feature-hero`, `signal-accordion` and `signal-callout` descriptions match their packs.

  • 1e481f4: `BLOCK_CATALOG` regenerated so the `banner`, `feature-hero`, `signal-accordion` and `signal-callout` descriptions match their packs.
  • 252ef2d: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
  • cbbc37c: Text Reveal no longer promises a word-by-word scroll animation it does not have; its text renders fully visible, and its meta now says so. - `@wabbit/tome-blocks-editorial-pack`: the `text-reveal` block meta carries the corrected description, summary and role. - `@wabbit/tome-blocks-core`: the generated `BLOCK_CATALOG` entry for `text-reveal` picks up the same corrected description and role.
v0.18.0minor

c3468b0: New server-safe pack helpers, so block packs stop copying the same boilerplate (2026-09-24 audit A3 #8). `createPackRegistrar(blocks, bundle)` builds a pack's idempotent `register(blockRegistry, bundleRegistry)` entry point; `createLayerProbe(layerName, load)` is the memoized "is this Tome layer registered?" check for domain packs (the pack keeps the dynamic `import('@wabbit/tome-core/…')` literal, so blocks-core still has no core dependency); both ship from `./registry` and the root. `mediaRelation(config)` returns the `relationTo` for a media field (`config.mediaCollection ?? 'media'`, typed as `CollectionSlug`); it ships from `./defineBlock` and the root. No new subpaths.

  • c3468b0: New server-safe pack helpers, so block packs stop copying the same boilerplate (2026-09-24 audit A3 #8). `createPackRegistrar(blocks, bundle)` builds a pack's idempotent `register(blockRegistry, bundleRegistry)` entry point; `createLayerProbe(layerName, load)` is the memoized "is this Tome layer registered?" check for domain packs (the pack keeps the dynamic `import('@wabbit/tome-core/…')` literal, so blocks-core still has no core dependency); both ship from `./registry` and the root. `mediaRelation(config)` returns the `relationTo` for a media field (`config.mediaCollection ?? 'media'`, typed as `CollectionSlug`); it ships from `./defineBlock` and the root. No new subpaths.
v0.17.1patch

15006ce: README: correct the registry-access step. It said free-tier packs install anonymously; the registry now requires an install token for every read (free packs and shared foundation packages included). The walkthrough now explains the free path — a no-card account at wabbit.com/signup, a token from Account → Credentials, placed in the user-level `~/.npmrc` — what a free token covers (the two free packs plus `blocks-core`, `blocks-extras`, `blocks-house`, `tome-ui`), and links the customer guide at wabbit.com/docs/get-started. Docs only; no code change.

  • 15006ce: README: correct the registry-access step. It said free-tier packs install anonymously; the registry now requires an install token for every read (free packs and shared foundation packages included). The walkthrough now explains the free path — a no-card account at wabbit.com/signup, a token from Account → Credentials, placed in the user-level `~/.npmrc` — what a free token covers (the two free packs plus `blocks-core`, `blocks-extras`, `blocks-house`, `tome-ui`), and links the customer guide at wabbit.com/docs/get-started. Docs only; no code change.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.6patch

e044594: `RenderBlock` accepts an optional `context` prop and forwards it to the renderer as `BlockRenderProps.context` (typed `unknown`; each pack narrows its own shape). Lets a host pass per-page data — e.g. a deal's live totals — to data-bound blocks without a React context provider. Omitting it changes nothing.

  • e044594: `RenderBlock` accepts an optional `context` prop and forwards it to the renderer as `BlockRenderProps.context` (typed `unknown`; each pack narrows its own shape). Lets a host pass per-page data — e.g. a deal's live totals — to data-bound blocks without a React context provider. Omitting it changes nothing.
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 57875ba: Block-adapter registry: each `registerBlockAdapters()` call is stamped with the React graph it was made from (`'server'` for the RSC graph, `'client'` for anything that can run hooks — browser and SSR), and the `*ContextFallback` components read the registry with `{ graph: currentAdapterGraph() }`, so a registration made from the other graph is treated as absent instead of rendered. On the server the RSC graph and the SSR graph share one `globalThis`; a component registered from the RSC graph holds client REFERENCES where it renders `'use client'` components, and rendering it inside the SSR pass calls that reference as a function ("Attempted to call the default export of … from the server"). Observed on wabbit.com/blocks/\_preview: a directive-free setup module registered the site's link component from the RSC graph, the preview harness rendered pack CTAs in a client tree with no `<LinkAdapterProvider>`, and every link-bearing block returned 500. Now that case renders the plain-anchor fallback and warns once in development, pointing at the provider. Ungated reads (`getLinkAdapter()` with no options) and server-graph reads are unchanged. New exports: `AdapterGraph`, `currentAdapterGraph`, and optional `{ graph }` on the register/get functions.
  • 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.
  • 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.
  • 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source already carries.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
v0.15.24patch

1471078: Post Hero and Custom Hero catalog copy now describes what the blocks render, not their upstream military lore. Post Hero renders a full-bleed cover-image header with an accent-marked category tag, a visibility badge, and a mono byline strip — nothing about it reads "SITREP tactical," so the description, editorial role, and both variant descriptions now say what the reader sees. Custom Hero's description drops the "COP tactical layouts / SITREP post headers" jargon for plain treatment names. blocks-core BLOCK_CATALOG mirror entries updated to match. Enum IDs (`sitrep1`, `cop1`…) and schema field names are unchanged, per the 71d3b09 purge discipline.

  • 1471078: Post Hero and Custom Hero catalog copy now describes what the blocks render, not their upstream military lore. Post Hero renders a full-bleed cover-image header with an accent-marked category tag, a visibility badge, and a mono byline strip — nothing about it reads "SITREP tactical," so the description, editorial role, and both variant descriptions now say what the reader sees. Custom Hero's description drops the "COP tactical layouts / SITREP post headers" jargon for plain treatment names. blocks-core BLOCK_CATALOG mirror entries updated to match. Enum IDs (`sitrep1`, `cop1`…) and schema field names are unchanged, per the 71d3b09 purge discipline.
v0.15.9patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.15.8patch

6779aa1: Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration). - **blocks-org-pack:** CampaignBanner's `codename` field is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGrid `rank` fields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout. - **tome-org:** flavored admin LABELS get neutral text while stored values stay put — Event status `boarding`/`debrief` labeled "Check-In"/"Wrap-Up"; eventType `operation`/`patrol`/`exam` labeled "Initiative"/"Outreach"/"Assessment"; `securityLevel` labeled "Access"; Campaign `codename` labeled "Internal Name" and campaignType `recurring_op`/`special_operation`/`deployment` labeled "Recurring Series"/"Special Initiative"/"Rollout"; Member `classification` labeled "Directory Visibility" with `classified` labeled "Private", "Chain of command" → "Reporting line"; Rank `securityClearance` labeled "Access Level" and category `command` labeled "Management"; Squad squadType `fire_team`/`flight` labeled "Crew"/"Pod", `callsign` labeled "Nickname"; Membership `squadron` labeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", category `command` labeled "Executive". The configurable `DEFAULT_ORG_TERMINOLOGY` (Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and `@wabbit/tome-sc` inherits it for its themed collections. - **blocks-core:** BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).

  • 6779aa1: Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration). - **blocks-org-pack:** CampaignBanner's `codename` field is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGrid `rank` fields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout. - **tome-org:** flavored admin LABELS get neutral text while stored values stay put — Event status `boarding`/`debrief` labeled "Check-In"/"Wrap-Up"; eventType `operation`/`patrol`/`exam` labeled "Initiative"/"Outreach"/"Assessment"; `securityLevel` labeled "Access"; Campaign `codename` labeled "Internal Name" and campaignType `recurring_op`/`special_operation`/`deployment` labeled "Recurring Series"/"Special Initiative"/"Rollout"; Member `classification` labeled "Directory Visibility" with `classified` labeled "Private", "Chain of command" → "Reporting line"; Rank `securityClearance` labeled "Access Level" and category `command` labeled "Management"; Squad squadType `fire_team`/`flight` labeled "Crew"/"Pod", `callsign` labeled "Nickname"; Membership `squadron` labeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", category `command` labeled "Executive". The configurable `DEFAULT_ORG_TERMINOLOGY` (Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and `@wabbit/tome-sc` inherits it for its themed collections. - **blocks-core:** BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
v0.14.0minor

Add `LinkAdapter` — the third block adapter, alongside `RichTextAdapter` and `MediaAdapter`. Every link-rendering block in every pack emits a raw `<a href>`, which is a full document load. A pack cannot import a consuming site's route-transition component (wrong layer direction), and a site cannot reach inside a pack's render tree to swap the anchor — so pack links could never participate in a consumer's page transition, by construction. ```ts registerBlockAdapters({ link: { Link: MyTransitionLink, isLinkActive: true }, }); resolveLink({ href: block.ctaUrl, className: styles.cta, children: text }); ``` Mirrors the existing adapters exactly: module registry + `'use client'` Context fallback, with `resolveLink` statically importing `LinkContextFallback` so the bundler cuts the client boundary at build time. **One deliberate divergence:** the unregistered default is fully functional, not a stub. `NOOP_LINK_ADAPTER` renders a real `<a>` and `resolveLink` never dev-warns. Rich text and media have no meaningful fallback, so their absence is always a misconfiguration. A link does have one: navigate. Losing the transition is degraded; losing the navigation is broken — so a plain anchor is a supported end state for a standalone pack. New exports: `resolveLink`, `LinkAdapterContext`, `LinkAdapterProvider`, `useLinkAdapter`, `NOOP_LINK_ADAPTER`, and the `LinkProps` / `LinkAdapter` types. `BlockAdapters` gains an optional `link` slot. **Fully additive** — no existing adapter, export, or block behavior changes. Pack renderer migration off raw `<a>` is a follow-up.

  • Add `LinkAdapter` — the third block adapter, alongside `RichTextAdapter` and `MediaAdapter`. Every link-rendering block in every pack emits a raw `<a href>`, which is a full document load. A pack cannot import a consuming site's route-transition component (wrong layer direction), and a site cannot reach inside a pack's render tree to swap the anchor — so pack links could never participate in a consumer's page transition, by construction. ```ts registerBlockAdapters({ link: { Link: MyTransitionLink, isLinkActive: true }, }); resolveLink({ href: block.ctaUrl, className: styles.cta, children: text }); ``` Mirrors the existing adapters exactly: module registry + `'use client'` Context fallback, with `resolveLink` statically importing `LinkContextFallback` so the bundler cuts the client boundary at build time. **One deliberate divergence:** the unregistered default is fully functional, not a stub. `NOOP_LINK_ADAPTER` renders a real `<a>` and `resolveLink` never dev-warns. Rich text and media have no meaningful fallback, so their absence is always a misconfiguration. A link does have one: navigate. Losing the transition is degraded; losing the navigation is broken — so a plain anchor is a supported end state for a standalone pack. New exports: `resolveLink`, `LinkAdapterContext`, `LinkAdapterProvider`, `useLinkAdapter`, `NOOP_LINK_ADAPTER`, and the `LinkProps` / `LinkAdapter` types. `BlockAdapters` gains an optional `link` slot. **Fully additive** — no existing adapter, export, or block behavior changes. Pack renderer migration off raw `<a>` is a follow-up.
v0.13.0minor

f4d55c9: render: shared `Reveal` (scroll-reveal client wrapper) + `renderEmphasis` (asterisk→em) helpers for showcase variant graduation. Ported verbatim from tome-starter's `src/components/Reveal` and `src/utilities/renderEmphasis` (showcase program Phase 3.7) so pack renderers graduating starter-native showcase variants (e.g. `@wabbit/tome-blocks-marketing-starter`'s cta `door-strip` / `doors`) can share them instead of vendoring copies. Both exported from the `./render` subpath.

  • f4d55c9: render: shared `Reveal` (scroll-reveal client wrapper) + `renderEmphasis` (asterisk→em) helpers for showcase variant graduation. Ported verbatim from tome-starter's `src/components/Reveal` and `src/utilities/renderEmphasis` (showcase program Phase 3.7) so pack renderers graduating starter-native showcase variants (e.g. `@wabbit/tome-blocks-marketing-starter`'s cta `door-strip` / `doors`) can share them instead of vendoring copies. Both exported from the `./render` subpath.
v0.11.2patch

e11d5a2: Fix `resolveMedia`/`resolveRichText` crashing pages with `TypeError: Cannot read properties of null (reading 'useContext')` during RSC render. Both resolvers preferred a "registered adapter fast path" — `createElement(getMediaAdapter().Media, …)` on a component read from the globalThis registry at runtime. A bundler cannot statically trace that runtime reference to the adapter's module, so it never cuts the RSC client-reference boundary for the component's subtree. When the registered `Media` renders an intrinsically-client primitive (`next/image`, which calls `useContext`), that primitive executes during the Flight serialization pass with a null dispatcher and throws — 500-ing any page whose **server** pack renderer (e.g. `editorialFigure`) resolves media. It reproduces even when the registered component itself carries `'use client'`; the boundary is defeated by the runtime `createElement`, not the directive. (Verified against wabbit-site-core `/static-site-launch`, 2026-07-15.) Both resolvers now ALWAYS defer to their statically-imported `'use client'` fallback component (`MediaContextFallback` / `RichTextContextFallback`), which the bundler CAN boundary at build time. Adapter resolution (Context override first, module registry second) moves into those components via new hook-free `resolveActiveMediaAdapter` / `resolveActiveRichTextAdapter` helpers, so both adapter sources still work, including for a server component with no provider ancestor. Consequence: media/rich-text now always render inside a client boundary (no zero-JS server-only path). This is unavoidable for any `next/image`-based adapter, which is intrinsically client — mirroring the spec's existing "motion is intrinsically client" carve-out. A future opt-in `isServerSafe` adapter flag could restore the zero-JS registry path for provably `<img>`-only adapters; it is deliberately not the default, because the default must be correct.

  • e11d5a2: Fix `resolveMedia`/`resolveRichText` crashing pages with `TypeError: Cannot read properties of null (reading 'useContext')` during RSC render. Both resolvers preferred a "registered adapter fast path" — `createElement(getMediaAdapter().Media, …)` on a component read from the globalThis registry at runtime. A bundler cannot statically trace that runtime reference to the adapter's module, so it never cuts the RSC client-reference boundary for the component's subtree. When the registered `Media` renders an intrinsically-client primitive (`next/image`, which calls `useContext`), that primitive executes during the Flight serialization pass with a null dispatcher and throws — 500-ing any page whose **server** pack renderer (e.g. `editorialFigure`) resolves media. It reproduces even when the registered component itself carries `'use client'`; the boundary is defeated by the runtime `createElement`, not the directive. (Verified against wabbit-site-core `/static-site-launch`, 2026-07-15.) Both resolvers now ALWAYS defer to their statically-imported `'use client'` fallback component (`MediaContextFallback` / `RichTextContextFallback`), which the bundler CAN boundary at build time. Adapter resolution (Context override first, module registry second) moves into those components via new hook-free `resolveActiveMediaAdapter` / `resolveActiveRichTextAdapter` helpers, so both adapter sources still work, including for a server component with no provider ancestor. Consequence: media/rich-text now always render inside a client boundary (no zero-JS server-only path). This is unavoidable for any `next/image`-based adapter, which is intrinsically client — mirroring the spec's existing "motion is intrinsically client" carve-out. A future opt-in `isServerSafe` adapter flag could restore the zero-JS registry path for provably `<img>`-only adapters; it is deliberately not the default, because the default must be correct.
v0.11.0patch

26dfa07: Pre-existing test-suite fixes (unrelated to recent feature work): - `high-impact-hero`'s `illustrationHero` variant carried two tags (`brand`, `illustration`) outside the canonical taxonomy declared in `v2-coverage.test.ts`. Fixed to `['editorial', 'playful']`. The `illustrationHero` slug itself is kept camelCase (not renamed to kebab-case) because it shipped in the published `0.5.0` release (2026-05-21) and is stored verbatim in consumer content as a `_variant` field value — renaming would silently break every stored document that already selected it. The kebab-case rule now carries a documented, slug-scoped exception with a deprecation trigger (next content-breaking major version for this package, when a stored-content migration pass is already budgeted). - `blocks-core`'s `test/top20-variants.test.ts` imported `@wabbit/tome-blocks-marketing-starter` and the other bundle packs directly, which a later refactor made unresolvable when it removed blocks-core's devDeps on those packs to break the blocks-core ↔ marketing-starter dependency cycle. Relocated the test to `@wabbit/tome-blocks` (the meta-package that already depends on every pack for exactly this purpose) rather than re-adding the removed devDeps and recreating the cycle. Test-only change; no runtime behavior changed in either package.

  • 26dfa07: Pre-existing test-suite fixes (unrelated to recent feature work): - `high-impact-hero`'s `illustrationHero` variant carried two tags (`brand`, `illustration`) outside the canonical taxonomy declared in `v2-coverage.test.ts`. Fixed to `['editorial', 'playful']`. The `illustrationHero` slug itself is kept camelCase (not renamed to kebab-case) because it shipped in the published `0.5.0` release (2026-05-21) and is stored verbatim in consumer content as a `_variant` field value — renaming would silently break every stored document that already selected it. The kebab-case rule now carries a documented, slug-scoped exception with a deprecation trigger (next content-breaking major version for this package, when a stored-content migration pass is already budgeted). - `blocks-core`'s `test/top20-variants.test.ts` imported `@wabbit/tome-blocks-marketing-starter` and the other bundle packs directly, which a later refactor made unresolvable when it removed blocks-core's devDeps on those packs to break the blocks-core ↔ marketing-starter dependency cycle. Relocated the test to `@wabbit/tome-blocks` (the meta-package that already depends on every pack for exactly this purpose) rather than re-adding the removed devDeps and recreating the cycle. Test-only change; no runtime behavior changed in either package.
  • 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.
  • 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: Gallery browse performance: search input debounced at 80ms via a local-state + `useDebouncedValue` split (instant keystroke echo, one grid re-filter per pause; immune to context-sibling re-render stomps), `BlockCard` wrapped in `React.memo` (props verified referentially stable — unrelated re-renders no longer re-run every card's hover/visibility machinery). blocks-core publishes `./utilities/useDocumentTheme` as a lean subpath (mirroring `useDebouncedResize`) so Payload-free consumers can reach the theme hook without the admin barrel's `@payloadcms/ui` graph; gallery's local MutationObserver copy replaced with a narrowing wrapper over the canonical hook. Also: BlockPicker's artificial `setTimeout(0)` skeleton delay removed; RecentFavorites static data-attributes moved from an effect into JSX.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • 5f78397: Server-safe adapter contract (spec 2026-07-12, waves M0–M1). blocks-core gains `./adapters`: `registerBlockAdapters({ richText?, media? })` (explicit, idempotent, lazily globalThis-anchored — layerRegistry pattern) plus environment-agnostic `resolveRichText(value, opts?)` / `resolveMedia(value, opts?)` callable from RSC and client alike. The adapter React Contexts now live in blocks-core (`adapters/context.tsx`); blocks-extras' adapter modules are thin re-exports (zero API break) and its Providers additionally sync their adapter into the registry (guarded write-during-render, documented). Unregistered-registry resolution returns a client fallback element that reads the Context — provider-based sites see zero behavior change even when migrated blocks execute as Server Components; sites that call `registerBlockAdapters` from a module in both graphs get pure server rendering. Migration contract for consumers: call `registerBlockAdapters` at config/app scope when adopting RSC-rendered blocks; the `useRichTextAdapter`/`useMediaAdapter` hooks remain functional (deprecated-in-place; removal trigger in the spec).
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.10.0minor

BlockPicker raster thumbnails: new GalleryThumbsProvider/ThumbnailSourceProvider API and thumbsManifestUrl prop render real gallery screenshot captures in the admin picker with a raster → authored SVG → placeholder fallback chain; shared useDocumentTheme utility; placeholder SVG migrated to canonical --tome-color-\* tokens.

  • BlockPicker raster thumbnails: new GalleryThumbsProvider/ThumbnailSourceProvider API and thumbsManifestUrl prop render real gallery screenshot captures in the admin picker with a raster → authored SVG → placeholder fallback chain; shared useDocumentTheme utility; placeholder SVG migrated to canonical --tome-color-\* tokens.
v0.9.4patch

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.
v0.9.2patch

D3 breakout: additive optional `BlockMeta` fields `defaultBreakout`/`pinnedBand` for per-block breakout policy (`pinnedBand` is what the D6 Rule-D forward-tripwire guards).

  • D3 breakout: additive optional `BlockMeta` fields `defaultBreakout`/`pinnedBand` for per-block breakout policy (`pinnedBand` is what the D6 Rule-D forward-tripwire guards).
v0.9.0minor

c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.

  • c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
v0.8.0minor

249b670: Batch 0 of the inserter/variant architecture (2026-06-27 spec): wire the variant picker onto `_variant` and ship the per-block usage/intent layer. - **Variant picker on `_variant`** — the auto-injected `_variant` field is no longer `admin.hidden`; it renders the thumbnail `VariantPickerField` (a new `useField` adapter exported from `/admin`), referenced by the `@wabbit/tome-blocks-core/admin` package path (lazy via importMap — never enters the config graph). `defineBlock` threads `blockSlug` + a serializable variant list through `clientProps`. This makes declared variants (e.g. `editorialSpread`'s 5) editor-reachable for the first time. Degrades to a native select when no thumbnails are ingested. - **Render-registry variant dimension** — `registerVariantRenderer` / `registerVariantRenderers` / `hasVariantRenderer` / `resolveRenderer` add a `{variant → component}` dispatch alongside the existing slug-keyed registry (additive, back-compat). - **Per-block usage/intent layer** — additive optional `BlockMeta.usage` (`BlockUsage`: summary / whenToUse / pageTypes / howToUse / pairsWith / sequence / avoidWhen / register) following the `requiresMotion`/`requiredCapabilities` precedent, plus a new `buildUsageManifest()` (`./usage` subpath) that flattens descriptors into a selection/sequencing index for assembling agents. Exemplar authored on `editorialSpread`. - Adds `@payloadcms/ui` as an **optional** peer dependency (only the `/admin` subpath needs it). Linked family aligns to 0.8.0. No breaking API changes (all additive; `_variant` becoming visible is a behavior change, not an API removal).

  • 249b670: Batch 0 of the inserter/variant architecture (2026-06-27 spec): wire the variant picker onto `_variant` and ship the per-block usage/intent layer. - **Variant picker on `_variant`** — the auto-injected `_variant` field is no longer `admin.hidden`; it renders the thumbnail `VariantPickerField` (a new `useField` adapter exported from `/admin`), referenced by the `@wabbit/tome-blocks-core/admin` package path (lazy via importMap — never enters the config graph). `defineBlock` threads `blockSlug` + a serializable variant list through `clientProps`. This makes declared variants (e.g. `editorialSpread`'s 5) editor-reachable for the first time. Degrades to a native select when no thumbnails are ingested. - **Render-registry variant dimension** — `registerVariantRenderer` / `registerVariantRenderers` / `hasVariantRenderer` / `resolveRenderer` add a `{variant → component}` dispatch alongside the existing slug-keyed registry (additive, back-compat). - **Per-block usage/intent layer** — additive optional `BlockMeta.usage` (`BlockUsage`: summary / whenToUse / pageTypes / howToUse / pairsWith / sequence / avoidWhen / register) following the `requiresMotion`/`requiredCapabilities` precedent, plus a new `buildUsageManifest()` (`./usage` subpath) that flattens descriptors into a selection/sequencing index for assembling agents. Exemplar authored on `editorialSpread`. - Adds `@payloadcms/ui` as an **optional** peer dependency (only the `/admin` subpath needs it). Linked family aligns to 0.8.0. No breaking API changes (all additive; `_variant` becoming visible is a behavior change, not an API removal).
v0.7.0minor

66c611c: B1 — block tiering formalization + blocks-extras free/paid split. - **blocks-core:** add `'addon'` to the `BundleMeta.tier` union (additive; existing `'pro'` values stay valid). - **blocks-extras:** register TWO bundles from one package (Option B) — a free `extras-primitives` sub-bundle (9 structural primitives) + the paid `extras` remainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existing `extras` entitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - **signal-theme + sc-pack:** tier `'pro'` → `'addon'` (sold independently of the tiered subscription track). - **blocks-gallery:** widen the `@wabbit/tome-blocks-core` peer to `^0.5.9 || ^0.6.0 || ^0.7.0` so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch. Release note: the blocks family is `linked`, so this aligns the whole family to **0.7.0**. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (`^0.6` does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.

  • 66c611c: B1 — block tiering formalization + blocks-extras free/paid split. - **blocks-core:** add `'addon'` to the `BundleMeta.tier` union (additive; existing `'pro'` values stay valid). - **blocks-extras:** register TWO bundles from one package (Option B) — a free `extras-primitives` sub-bundle (9 structural primitives) + the paid `extras` remainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existing `extras` entitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - **signal-theme + sc-pack:** tier `'pro'` → `'addon'` (sold independently of the tiered subscription track). - **blocks-gallery:** widen the `@wabbit/tome-blocks-core` peer to `^0.5.9 || ^0.6.0 || ^0.7.0` so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch. Release note: the blocks family is `linked`, so this aligns the whole family to **0.7.0**. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (`^0.6` does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.
v0.6.2patch

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.
v0.5.9patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-catalog@1.1.3

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-catalog@1.1.3
v0.5.7patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11 - @wabbit/tome-catalog@1.1.2
v0.5.1patch

Add a `./fields/columnSpan` export to the package `exports` map so registry consumers can import the shared `columnSpan` field factory directly. Under path-alias consumption the deep path resolved against source; the `exports` map enforces it under registry consumption. Surfaced by tome-starter's `Section/config.ts` during the move to registry consumption.

  • Add a `./fields/columnSpan` export to the package `exports` map so registry consumers can import the shared `columnSpan` field factory directly. Under path-alias consumption the deep path resolved against source; the `exports` map enforces it under registry consumption. Surfaced by tome-starter's `Section/config.ts` during the move to registry consumption.
v0.5.0minor

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

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

**Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.

  • **Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.
v0.4.0minor

b76f684: **Register `block-bundle` as a Tier 1 product type + wire Tier 2 `getCardFields` hook.** `@wabbit/tome-blocks-core` now self-registers `block-bundle` in `@wabbit/tome-catalog`'s `productTypeRegistry` at module-load (mirrors the catalog defaults pattern). Bundles previously lived only in the in-memory `BundleRegistry`; they now appear in catalog admin's product type dropdown. Also wires a `getCardFields` hook against `@wabbit/tome-core/registry/productTypeHookRegistry` for slug `'block-bundle'`. The hook resolves linked bundles via `catalog-products.metadata.bundleSlug` → `bundleRegistry.get(slug)` (a new platform convention — documented in this changeset; will graduate to spec when a 2nd consumer adopts). Returns enriched card fields (`Bundle · N blocks` badge, block count metadata) when the bundle is registered; degrades gracefully with product-only fields otherwise.

  • b76f684: **Register `block-bundle` as a Tier 1 product type + wire Tier 2 `getCardFields` hook.** `@wabbit/tome-blocks-core` now self-registers `block-bundle` in `@wabbit/tome-catalog`'s `productTypeRegistry` at module-load (mirrors the catalog defaults pattern). Bundles previously lived only in the in-memory `BundleRegistry`; they now appear in catalog admin's product type dropdown. Also wires a `getCardFields` hook against `@wabbit/tome-core/registry/productTypeHookRegistry` for slug `'block-bundle'`. The hook resolves linked bundles via `catalog-products.metadata.bundleSlug` → `bundleRegistry.get(slug)` (a new platform convention — documented in this changeset; will graduate to spec when a 2nd consumer adopts). Returns enriched card fields (`Bundle · N blocks` badge, block count metadata) when the bundle is registered; degrades gracefully with product-only fields otherwise.
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

Blocks

v0.50.0
v0.50.0patch

Updated dependencies [2288bb9] - @wabbit/tome-blocks-marketing-starter@0.50.0

  • Updated dependencies [2288bb9] - @wabbit/tome-blocks-marketing-starter@0.50.0
v0.49.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
  • Updated dependencies [dd19c82]
  • Updated dependencies [dd19c82]
  • Updated dependencies [dd19c82]
  • Updated dependencies [deea397] - @wabbit/tome-blocks-core@0.49.4 - @wabbit/tome-blocks-extras@0.49.4 - @wabbit/tome-blocks-marketing-starter@0.49.4 - @wabbit/tome-blocks-content-writer@0.49.4
v0.49.0patch

Updated dependencies

  • Updated dependencies
  • Updated dependencies [d08e2ee]
  • Updated dependencies [d08e2ee] - @wabbit/tome-blocks-core@0.49.0 - @wabbit/tome-blocks-extras@0.49.0 - @wabbit/tome-blocks-marketing-starter@0.49.0 - @wabbit/tome-blocks-content-writer@0.43.3
v0.48.0patch

Updated dependencies [31e0105]

  • Updated dependencies [31e0105]
  • Updated dependencies [3954ae9] - @wabbit/tome-blocks-extras@0.48.0 - @wabbit/tome-blocks-core@0.48.0 - @wabbit/tome-blocks-marketing-starter@0.47.0 - @wabbit/tome-blocks-content-writer@0.43.3
v0.47.0patch

Updated dependencies [b6b66ea] - @wabbit/tome-blocks-marketing-starter@0.47.0

  • Updated dependencies [b6b66ea] - @wabbit/tome-blocks-marketing-starter@0.47.0
v0.46.0patch

Updated dependencies [ba8c8f6]

  • Updated dependencies [ba8c8f6]
  • Updated dependencies [39e0892] - @wabbit/tome-blocks-core@0.46.0 - @wabbit/tome-blocks-extras@0.46.0 - @wabbit/tome-blocks-content-writer@0.43.3 - @wabbit/tome-blocks-marketing-starter@0.45.0
v0.45.0patch

Updated dependencies [a6eaeb2] - @wabbit/tome-blocks-core@0.45.0 - @wabbit/tome-blocks-marketing-starter@0.45.0 - @wabbit/tome-blocks-content-writer@0.43.3 - @wabbit/tome-blocks-extras@0.44.0

  • Updated dependencies [a6eaeb2] - @wabbit/tome-blocks-core@0.45.0 - @wabbit/tome-blocks-marketing-starter@0.45.0 - @wabbit/tome-blocks-content-writer@0.43.3 - @wabbit/tome-blocks-extras@0.44.0
v0.44.0patch

Updated dependencies [7f26e12]

  • Updated dependencies [7f26e12]
  • Updated dependencies [8d7c046]
  • Updated dependencies [d0f11bc]
  • Updated dependencies [822d3f9] - @wabbit/tome-blocks-marketing-starter@0.44.0 - @wabbit/tome-blocks-core@0.44.0 - @wabbit/tome-blocks-extras@0.44.0 - @wabbit/tome-blocks-content-writer@0.43.3
v0.43.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
  • Updated dependencies [d51ad89]
  • Updated dependencies [d51ad89]
  • Updated dependencies [d51ad89]
  • Updated dependencies [d51ad89] - @wabbit/tome-blocks-content-writer@0.43.2 - @wabbit/tome-blocks-core@0.43.2 - @wabbit/tome-blocks-extras@0.43.2 - @wabbit/tome-blocks-marketing-starter@0.43.2
v0.43.0patch

Updated dependencies [0cb0e16] - @wabbit/tome-blocks-content-writer@0.43.0

  • Updated dependencies [0cb0e16] - @wabbit/tome-blocks-content-writer@0.43.0
v0.42.0patch

Updated dependencies [39cb8ef] - @wabbit/tome-blocks-core@0.42.0 - @wabbit/tome-blocks-content-writer@0.40.1 - @wabbit/tome-blocks-extras@0.38.0 - @wabbit/tome-blocks-marketing-starter@0.39.0

  • Updated dependencies [39cb8ef] - @wabbit/tome-blocks-core@0.42.0 - @wabbit/tome-blocks-content-writer@0.40.1 - @wabbit/tome-blocks-extras@0.38.0 - @wabbit/tome-blocks-marketing-starter@0.39.0
v0.40.0patch

Updated dependencies [9a82103] - @wabbit/tome-blocks-content-writer@0.40.0

  • Updated dependencies [9a82103] - @wabbit/tome-blocks-content-writer@0.40.0
v0.39.0patch

Updated dependencies [af33c85]

  • Updated dependencies [af33c85]
  • Updated dependencies [45db383] - @wabbit/tome-blocks-marketing-starter@0.39.0
v0.38.0patch

Updated dependencies [06221cd]

  • Updated dependencies [06221cd]
  • Updated dependencies [7cb9c04]
  • Updated dependencies [cff5b22]
  • Updated dependencies [06221cd]
  • Updated dependencies [36adebe] - @wabbit/tome-blocks-core@0.38.0 - @wabbit/tome-blocks-content-writer@0.38.0 - @wabbit/tome-blocks-extras@0.38.0 - @wabbit/tome-blocks-marketing-starter@0.38.0
v0.37.0patch

Updated dependencies [d69161c]

  • Updated dependencies [d69161c]
  • Updated dependencies [05c572a]
  • Updated dependencies [3d47fcd]
  • Updated dependencies [0e56651] - @wabbit/tome-blocks-core@0.37.0 - @wabbit/tome-blocks-content-writer@0.37.0 - @wabbit/tome-blocks-extras@0.37.0 - @wabbit/tome-blocks-marketing-starter@0.37.0
v0.36.0patch

Updated dependencies [2e9f1ab]

  • Updated dependencies [2e9f1ab]
  • Updated dependencies [b1c933c] - @wabbit/tome-blocks-marketing-starter@0.36.0 - @wabbit/tome-blocks-extras@0.36.0 - @wabbit/tome-blocks-core@0.35.0
v0.35.0patch

Updated dependencies [f4a08ca]

  • Updated dependencies [f4a08ca]
  • Updated dependencies [f4a08ca]
  • Updated dependencies [f4a08ca] - @wabbit/tome-blocks-core@0.35.0 - @wabbit/tome-blocks-marketing-starter@0.35.0 - @wabbit/tome-blocks-extras@0.35.0 - @wabbit/tome-blocks-content-writer@0.34.1
v0.34.0patch

Updated dependencies [e3aadfe]

  • Updated dependencies [e3aadfe]
  • Updated dependencies [3e4f0a7]
  • Updated dependencies [e8b4d32] - @wabbit/tome-blocks-core@0.34.0 - @wabbit/tome-blocks-marketing-starter@0.34.0 - @wabbit/tome-blocks-extras@0.34.0 - @wabbit/tome-blocks-content-writer@0.34.0
v0.33.0patch

Updated dependencies [a9424eb] - @wabbit/tome-blocks-marketing-starter@0.33.0

  • Updated dependencies [a9424eb] - @wabbit/tome-blocks-marketing-starter@0.33.0
v0.32.0patch

Updated dependencies [072f973] - @wabbit/tome-blocks-marketing-starter@0.32.0 - @wabbit/tome-blocks-extras@0.32.0

  • Updated dependencies [072f973] - @wabbit/tome-blocks-marketing-starter@0.32.0 - @wabbit/tome-blocks-extras@0.32.0
v0.31.0patch

Updated dependencies [5f2662c]

  • Updated dependencies [5f2662c]
  • Updated dependencies [fc84e67]
  • Updated dependencies [b8ccf85] - @wabbit/tome-blocks-marketing-starter@0.31.0 - @wabbit/tome-blocks-core@0.31.0 - @wabbit/tome-blocks-extras@0.31.0 - @wabbit/tome-blocks-content-writer@0.28.5
v0.30.0patch

Updated dependencies [9fbffc6]

  • Updated dependencies [9fbffc6]
  • Updated dependencies [fd848e0] - @wabbit/tome-blocks-core@0.30.0 - @wabbit/tome-blocks-marketing-starter@0.30.0 - @wabbit/tome-blocks-content-writer@0.28.5 - @wabbit/tome-blocks-extras@0.28.5
v0.29.0patch

Updated dependencies [115bcfb]

  • Updated dependencies [115bcfb]
  • Updated dependencies [a6802f8]
  • Updated dependencies [698c314] - @wabbit/tome-blocks-core@0.29.0 - @wabbit/tome-blocks-marketing-starter@0.29.0 - @wabbit/tome-blocks-content-writer@0.28.5 - @wabbit/tome-blocks-extras@0.28.5
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
  • Updated dependencies [775f90a] - @wabbit/tome-blocks-content-writer@0.28.5 - @wabbit/tome-blocks-core@0.28.5 - @wabbit/tome-blocks-extras@0.28.5 - @wabbit/tome-blocks-marketing-starter@0.28.5
v0.28.0patch

Updated dependencies [483e0a1]

  • Updated dependencies [483e0a1]
  • Updated dependencies [483e0a1]
  • Updated dependencies [483e0a1]
  • Updated dependencies [483e0a1]
  • Updated dependencies [868c087]
  • Updated dependencies [6301bf1]
  • Updated dependencies [6301bf1]
  • Updated dependencies [6301bf1]
  • Updated dependencies [868c087]
  • Updated dependencies [58655f4]
  • Updated dependencies [58655f4] - @wabbit/tome-blocks-core@0.28.0 - @wabbit/tome-blocks-content-writer@0.28.0 - @wabbit/tome-blocks-extras@0.28.0 - @wabbit/tome-blocks-marketing-starter@0.28.0
v0.27.0patch

Updated dependencies [c14a133]

  • Updated dependencies [c14a133]
  • Updated dependencies [c14a133]
  • Updated dependencies [c14a133]
  • Updated dependencies [c14a133] - @wabbit/tome-blocks-core@0.27.0 - @wabbit/tome-blocks-content-writer@0.27.0 - @wabbit/tome-blocks-extras@0.27.0 - @wabbit/tome-blocks-marketing-starter@0.27.0
v0.26.0patch

Updated dependencies [f52288f]

  • Updated dependencies [f52288f]
  • Updated dependencies [8c84e70] - @wabbit/tome-blocks-extras@0.26.0 - @wabbit/tome-blocks-marketing-starter@0.24.0 - @wabbit/tome-blocks-core@0.25.1
v0.24.0patch

Updated dependencies

  • Updated dependencies
  • Updated dependencies - @wabbit/tome-blocks-content-writer@0.24.0 - @wabbit/tome-blocks-marketing-starter@0.24.0
v0.22.0patch

Updated dependencies

  • Updated dependencies
  • Updated dependencies - @wabbit/tome-blocks-core@0.22.0 - @wabbit/tome-blocks-extras@0.22.0 - @wabbit/tome-blocks-content-writer@0.18.3 - @wabbit/tome-blocks-marketing-starter@0.20.1
v0.21.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.21.0 - @wabbit/tome-blocks-content-writer@0.18.3 - @wabbit/tome-blocks-extras@0.20.1 - @wabbit/tome-blocks-marketing-starter@0.20.1

  • Updated dependencies - @wabbit/tome-blocks-core@0.21.0 - @wabbit/tome-blocks-content-writer@0.18.3 - @wabbit/tome-blocks-extras@0.20.1 - @wabbit/tome-blocks-marketing-starter@0.20.1
v0.20.0minor

e941996: **BREAKING:** the paid block packs are now optional peer dependencies, so installing `@wabbit/tome-blocks` no longer fails for a registry credential that does not hold every paid pack. Hard dependencies are now only the open packs (`tome-blocks-core`, `tome-blocks-marketing-starter`, `tome-blocks-content-writer`, `tome-blocks-extras`). `tome-blocks-agency-essentials`, `-editorial-pack`, `-signal-theme`, `-lms-pack`, `-catalog-pack`, `-sc-pack` and `-org-pack` moved to optional `peerDependencies`. A new `@wabbit/tome-blocks/open` entry re-exports only the open packs, with `registerAll()` and `registerAllRenderers()` scoped to them. **Migration:** if you import the root entry (`@wabbit/tome-blocks` or `@wabbit/tome-blocks/render`), add every paid pack to your own dependencies — they are no longer installed for you. If you hold only some paid packs, import from `@wabbit/tome-blocks/open` and call each held pack's own `register()`.

  • e941996: **BREAKING:** the paid block packs are now optional peer dependencies, so installing `@wabbit/tome-blocks` no longer fails for a registry credential that does not hold every paid pack. Hard dependencies are now only the open packs (`tome-blocks-core`, `tome-blocks-marketing-starter`, `tome-blocks-content-writer`, `tome-blocks-extras`). `tome-blocks-agency-essentials`, `-editorial-pack`, `-signal-theme`, `-lms-pack`, `-catalog-pack`, `-sc-pack` and `-org-pack` moved to optional `peerDependencies`. A new `@wabbit/tome-blocks/open` entry re-exports only the open packs, with `registerAll()` and `registerAllRenderers()` scoped to them. **Migration:** if you import the root entry (`@wabbit/tome-blocks` or `@wabbit/tome-blocks/render`), add every paid pack to your own dependencies — they are no longer installed for you. If you hold only some paid packs, import from `@wabbit/tome-blocks/open` and call each held pack's own `register()`.
  • Updated dependencies [c94d653]
  • Updated dependencies [9ac8d3d] - @wabbit/tome-blocks-core@0.20.0 - @wabbit/tome-blocks-extras@0.20.0 - @wabbit/tome-blocks-content-writer@0.18.3 - @wabbit/tome-blocks-marketing-starter@0.18.3
v0.19.0patch

Updated dependencies [f6cfffa]

  • Updated dependencies [f6cfffa]
  • Updated dependencies [89a8338] - @wabbit/tome-blocks-sc-pack@0.19.0
v0.18.4patch

Corrective republish: 0.18.3 (like 0.18.0 before it) shipped every dependency as a literal `workspace:^` specifier when published through `changeset publish`; this version carries the resolved ranges. No code change.

  • Corrective republish: 0.18.3 (like 0.18.0 before it) shipped every dependency as a literal `workspace:^` specifier when published through `changeset publish`; this version carries the resolved ranges. No code change.
v0.18.3patch

6530765: Removed an unused CSS-copy build hook (this package ships no CSS of its own); no behavior change, and the published `dist/` is identical.

  • 6530765: Removed an unused CSS-copy build hook (this package ships no CSS of its own); no behavior change, and the published `dist/` is identical.
  • Updated dependencies [6530765]
  • Updated dependencies [6530765]
  • Updated dependencies [6530765]
  • Updated dependencies [6530765]
  • Updated dependencies [786853e]
  • Updated dependencies [6530765]
  • Updated dependencies [6530765]
  • Updated dependencies [6530765]
  • Updated dependencies [786853e]
  • Updated dependencies [786853e]
  • Updated dependencies [6530765]
  • Updated dependencies [6530765]
  • Updated dependencies [786853e] - @wabbit/tome-blocks-agency-essentials@0.18.3 - @wabbit/tome-blocks-catalog-pack@0.18.3 - @wabbit/tome-blocks-content-writer@0.18.3 - @wabbit/tome-blocks-core@0.18.3 - @wabbit/tome-blocks-editorial-pack@0.18.3 - @wabbit/tome-blocks-extras@0.18.3 - @wabbit/tome-blocks-marketing-starter@0.18.3 - @wabbit/tome-blocks-org-pack@0.5.3 - @wabbit/tome-blocks-signal-theme@0.18.3 - @wabbit/tome-blocks-lms-pack@0.18.3 - @wabbit/tome-blocks-sc-pack@0.18.0
v0.18.2patch

Corrective republish: 0.18.0 shipped every dependency as a literal `workspace:^` specifier (uninstallable); 0.18.1 carries the resolved versions. No code change.

  • Corrective republish: 0.18.0 shipped every dependency as a literal `workspace:^` specifier (uninstallable); 0.18.1 carries the resolved versions. No code change.
v0.18.0patch

Updated dependencies [c3468b0]

  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0] - @wabbit/tome-blocks-agency-essentials@0.18.0 - @wabbit/tome-blocks-catalog-pack@0.18.0 - @wabbit/tome-blocks-content-writer@0.18.0 - @wabbit/tome-blocks-core@0.18.0 - @wabbit/tome-blocks-editorial-pack@0.18.0 - @wabbit/tome-blocks-lms-pack@0.18.0 - @wabbit/tome-blocks-marketing-starter@0.18.0 - @wabbit/tome-blocks-org-pack@0.5.1 - @wabbit/tome-blocks-sc-pack@0.18.0 - @wabbit/tome-blocks-signal-theme@0.18.0 - @wabbit/tome-blocks-extras@0.17.0
v0.17.0patch

Updated dependencies [404d325] - @wabbit/tome-blocks-core@0.17.0 - @wabbit/tome-blocks-extras@0.17.0 - @wabbit/tome-blocks-marketing-starter@0.17.0 - @wabbit/tome-blocks-content-writer@0.17.0 - @wabbit/tome-blocks-agency-essentials@0.17.0 - @wabbit/tome-blocks-editorial-pack@0.17.0 - @wabbit/tome-blocks-signal-theme@0.17.0 - @wabbit/tome-blocks-lms-pack@0.17.0 - @wabbit/tome-blocks-catalog-pack@0.17.0 - @wabbit/tome-blocks-sc-pack@0.17.0 - @wabbit/tome-blocks-org-pack@0.5.0

  • Updated dependencies [404d325] - @wabbit/tome-blocks-core@0.17.0 - @wabbit/tome-blocks-extras@0.17.0 - @wabbit/tome-blocks-marketing-starter@0.17.0 - @wabbit/tome-blocks-content-writer@0.17.0 - @wabbit/tome-blocks-agency-essentials@0.17.0 - @wabbit/tome-blocks-editorial-pack@0.17.0 - @wabbit/tome-blocks-signal-theme@0.17.0 - @wabbit/tome-blocks-lms-pack@0.17.0 - @wabbit/tome-blocks-catalog-pack@0.17.0 - @wabbit/tome-blocks-sc-pack@0.17.0 - @wabbit/tome-blocks-org-pack@0.5.0
v0.16.6patch

1019136: Re-release with real dependency versions. 0.16.0 on the registry shipped its eleven `@wabbit/*` dependencies as literal `workspace:^` strings (packed by npm under `changeset publish`) and cannot be installed; this release is packed with `pnpm publish`, which rewrites them.

  • 1019136: Re-release with real dependency versions. 0.16.0 on the registry shipped its eleven `@wabbit/*` dependencies as literal `workspace:^` strings (packed by npm under `changeset publish`) and cannot be installed; this release is packed with `pnpm publish`, which rewrites them.
  • Updated dependencies [e044594] - @wabbit/tome-blocks-core@0.16.6
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [04309f5]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0 - @wabbit/tome-blocks-agency-essentials@0.16.0 - @wabbit/tome-blocks-catalog-pack@0.16.0 - @wabbit/tome-blocks-content-writer@0.16.0 - @wabbit/tome-blocks-editorial-pack@0.16.0 - @wabbit/tome-blocks-extras@0.16.0 - @wabbit/tome-blocks-lms-pack@0.16.0 - @wabbit/tome-blocks-marketing-starter@0.16.0 - @wabbit/tome-blocks-sc-pack@0.16.0 - @wabbit/tome-blocks-signal-theme@0.16.0 - @wabbit/tome-blocks-org-pack@0.4.0
v0.15.9patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-signal-theme@0.15.9 - @wabbit/tome-blocks-extras@0.15.9 - @wabbit/tome-blocks-content-writer@0.15.9 - @wabbit/tome-blocks-core@0.15.9 - @wabbit/tome-blocks-catalog-pack@0.15.9 - @wabbit/tome-blocks-lms-pack@0.15.9 - @wabbit/tome-blocks-org-pack@0.3.4 - @wabbit/tome-blocks-marketing-starter@0.15.6
v0.15.2patch

Republished via `pnpm publish` so the `workspace:*` dependency protocol is rewritten to real versions. **0.15.1 did not fix this.** `pnpm changeset publish` still ships the literal protocol string — changesets invokes `npm publish` internally regardless of the package manager used to launch it, so routing the _changeset_ command through pnpm changes nothing. Only `pnpm publish`, run from the package directory, applies pnpm's pack-time rewriting. **Release rule for this package:** `cd packages/blocks && pnpm publish`. Every other package in the monorepo is safe under `changeset publish`, because `@wabbit/tome-blocks` is the only one declaring `workspace:*` in `dependencies` rather than peer ranges. Broken versions that must not be installed: 0.11.0, 0.11.2, 0.13.0, 0.14.0, 0.15.0, 0.15.1.

  • Republished via `pnpm publish` so the `workspace:*` dependency protocol is rewritten to real versions. **0.15.1 did not fix this.** `pnpm changeset publish` still ships the literal protocol string — changesets invokes `npm publish` internally regardless of the package manager used to launch it, so routing the _changeset_ command through pnpm changes nothing. Only `pnpm publish`, run from the package directory, applies pnpm's pack-time rewriting. **Release rule for this package:** `cd packages/blocks && pnpm publish`. Every other package in the monorepo is safe under `changeset publish`, because `@wabbit/tome-blocks` is the only one declaring `workspace:*` in `dependencies` rather than peer ranges. Broken versions that must not be installed: 0.11.0, 0.11.2, 0.13.0, 0.14.0, 0.15.0, 0.15.1.
v0.15.1patch

Republish with the `workspace:*` dependency protocol resolved to real version ranges. `@wabbit/tome-blocks` is an umbrella package whose 11 runtime dependencies are declared `workspace:*`. That protocol is a pnpm authoring convenience and **must be rewritten to concrete ranges at pack time** — `pnpm publish` does this; `npm publish` does not, and ships the literal string. Installing such a tarball fails outright: ``` npm error code EUNSUPPORTEDPROTOCOL npm error Unsupported URL Type "workspace:": workspace:* ``` This is intermittent across the package's history — 0.11.0, 0.11.2, 0.13.0, 0.14.0 and 0.15.0 shipped broken while 0.10.3, 0.11.1, 0.11.3, 0.12.0 and 0.12.1 shipped clean — tracking whether that release happened to be cut with pnpm or npm. `changeset publish` shells out to `npm publish` unless it is itself invoked through pnpm, which is the mechanism behind the alternation. **This package must be released with `pnpm changeset publish`, never `npx changeset publish`.** No other package in the monorepo is affected: `@wabbit/tome-blocks` is the only one carrying `workspace:*` in `dependencies` rather than peer ranges.

  • Republish with the `workspace:*` dependency protocol resolved to real version ranges. `@wabbit/tome-blocks` is an umbrella package whose 11 runtime dependencies are declared `workspace:*`. That protocol is a pnpm authoring convenience and **must be rewritten to concrete ranges at pack time** — `pnpm publish` does this; `npm publish` does not, and ships the literal string. Installing such a tarball fails outright: ``` npm error code EUNSUPPORTEDPROTOCOL npm error Unsupported URL Type "workspace:": workspace:* ``` This is intermittent across the package's history — 0.11.0, 0.11.2, 0.13.0, 0.14.0 and 0.15.0 shipped broken while 0.10.3, 0.11.1, 0.11.3, 0.12.0 and 0.12.1 shipped clean — tracking whether that release happened to be cut with pnpm or npm. `changeset publish` shells out to `npm publish` unless it is itself invoked through pnpm, which is the mechanism behind the alternation. **This package must be released with `pnpm changeset publish`, never `npx changeset publish`.** No other package in the monorepo is affected: `@wabbit/tome-blocks` is the only one carrying `workspace:*` in `dependencies` rather than peer ranges.
v0.15.0patch

Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-agency-essentials@0.15.0 - @wabbit/tome-blocks-catalog-pack@0.15.0 - @wabbit/tome-blocks-content-writer@0.15.0 - @wabbit/tome-blocks-editorial-pack@0.15.0 - @wabbit/tome-blocks-extras@0.15.0 - @wabbit/tome-blocks-lms-pack@0.15.0 - @wabbit/tome-blocks-marketing-starter@0.15.0 - @wabbit/tome-blocks-org-pack@0.3.0 - @wabbit/tome-blocks-sc-pack@0.15.0 - @wabbit/tome-blocks-signal-theme@0.15.0

  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-agency-essentials@0.15.0 - @wabbit/tome-blocks-catalog-pack@0.15.0 - @wabbit/tome-blocks-content-writer@0.15.0 - @wabbit/tome-blocks-editorial-pack@0.15.0 - @wabbit/tome-blocks-extras@0.15.0 - @wabbit/tome-blocks-lms-pack@0.15.0 - @wabbit/tome-blocks-marketing-starter@0.15.0 - @wabbit/tome-blocks-org-pack@0.3.0 - @wabbit/tome-blocks-sc-pack@0.15.0 - @wabbit/tome-blocks-signal-theme@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-agency-essentials@0.14.0 - @wabbit/tome-blocks-catalog-pack@0.14.0 - @wabbit/tome-blocks-content-writer@0.14.0 - @wabbit/tome-blocks-editorial-pack@0.14.0 - @wabbit/tome-blocks-extras@0.14.0 - @wabbit/tome-blocks-lms-pack@0.14.0 - @wabbit/tome-blocks-marketing-starter@0.14.0 - @wabbit/tome-blocks-org-pack@0.2.10 - @wabbit/tome-blocks-sc-pack@0.14.0 - @wabbit/tome-blocks-signal-theme@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-agency-essentials@0.14.0 - @wabbit/tome-blocks-catalog-pack@0.14.0 - @wabbit/tome-blocks-content-writer@0.14.0 - @wabbit/tome-blocks-editorial-pack@0.14.0 - @wabbit/tome-blocks-extras@0.14.0 - @wabbit/tome-blocks-lms-pack@0.14.0 - @wabbit/tome-blocks-marketing-starter@0.14.0 - @wabbit/tome-blocks-org-pack@0.2.10 - @wabbit/tome-blocks-sc-pack@0.14.0 - @wabbit/tome-blocks-signal-theme@0.14.0
v0.13.0patch

Updated dependencies [f4d55c9]

  • Updated dependencies [f4d55c9]
  • Updated dependencies [f4d55c9]
  • Updated dependencies [9116174]
  • Updated dependencies [c041aea]
  • Updated dependencies [eb403d4] - @wabbit/tome-blocks-core@0.13.0 - @wabbit/tome-blocks-marketing-starter@0.13.0 - @wabbit/tome-blocks-editorial-pack@0.13.0 - @wabbit/tome-blocks-extras@0.13.0 - @wabbit/tome-blocks-agency-essentials@0.13.0 - @wabbit/tome-blocks-catalog-pack@0.13.0 - @wabbit/tome-blocks-content-writer@0.13.0 - @wabbit/tome-blocks-lms-pack@0.13.0 - @wabbit/tome-blocks-org-pack@0.2.9 - @wabbit/tome-blocks-sc-pack@0.13.0 - @wabbit/tome-blocks-signal-theme@0.13.0
v0.12.1patch

Updated dependencies - @wabbit/tome-blocks-lms-pack@0.12.1 - @wabbit/tome-blocks-catalog-pack@0.12.1 - @wabbit/tome-blocks-editorial-pack@0.12.1 - @wabbit/tome-blocks-signal-theme@0.12.1

  • Updated dependencies - @wabbit/tome-blocks-lms-pack@0.12.1 - @wabbit/tome-blocks-catalog-pack@0.12.1 - @wabbit/tome-blocks-editorial-pack@0.12.1 - @wabbit/tome-blocks-signal-theme@0.12.1
v0.12.0patch

Updated dependencies - @wabbit/tome-blocks-lms-pack@0.12.0 - @wabbit/tome-blocks-catalog-pack@0.12.0

  • Updated dependencies - @wabbit/tome-blocks-lms-pack@0.12.0 - @wabbit/tome-blocks-catalog-pack@0.12.0
v0.11.3patch

Corrective republish. `0.11.2` shipped all 11 of its dependencies as literal `workspace:*` specifiers (uninstallable — `EUNSUPPORTEDPROTOCOL`), the same intermittent `pnpm changeset publish` protocol-leak that hit `0.11.0` (fixed as `0.11.1`, commit 7d111b8). No code change — `pnpm publish` from the package directory rewrites the workspace ranges to resolved versions (verified via `pnpm pack` before upload: 0 leaks). `0.11.2` is deprecated.

  • Corrective republish. `0.11.2` shipped all 11 of its dependencies as literal `workspace:*` specifiers (uninstallable — `EUNSUPPORTEDPROTOCOL`), the same intermittent `pnpm changeset publish` protocol-leak that hit `0.11.0` (fixed as `0.11.1`, commit 7d111b8). No code change — `pnpm publish` from the package directory rewrites the workspace ranges to resolved versions (verified via `pnpm pack` before upload: 0 leaks). `0.11.2` is deprecated.
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-agency-essentials@0.11.2 - @wabbit/tome-blocks-catalog-pack@0.11.2 - @wabbit/tome-blocks-content-writer@0.11.2 - @wabbit/tome-blocks-editorial-pack@0.11.2 - @wabbit/tome-blocks-extras@0.11.2 - @wabbit/tome-blocks-lms-pack@0.11.2 - @wabbit/tome-blocks-marketing-starter@0.11.2 - @wabbit/tome-blocks-org-pack@0.2.8 - @wabbit/tome-blocks-sc-pack@0.11.2 - @wabbit/tome-blocks-signal-theme@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-agency-essentials@0.11.2 - @wabbit/tome-blocks-catalog-pack@0.11.2 - @wabbit/tome-blocks-content-writer@0.11.2 - @wabbit/tome-blocks-editorial-pack@0.11.2 - @wabbit/tome-blocks-extras@0.11.2 - @wabbit/tome-blocks-lms-pack@0.11.2 - @wabbit/tome-blocks-marketing-starter@0.11.2 - @wabbit/tome-blocks-org-pack@0.2.8 - @wabbit/tome-blocks-sc-pack@0.11.2 - @wabbit/tome-blocks-signal-theme@0.11.2
v0.11.1patch

Corrective republish: `0.11.0`'s published manifest shipped unrewritten `workspace:*` dependency specifiers (the only package of the 41 in the 2026-07-13 train to leak them — the `changeset publish` path skipped the rewrite for this package while `pnpm pack`/`pnpm publish` performs it correctly; mechanism unexplained, so the post-publish registry sweep now greps every published manifest for `workspace:`). `0.11.0` cannot be installed by npm consumers (`EUNSUPPORTEDPROTOCOL`) and is deprecated on the registry. No code changes — dist is byte-identical to `0.11.0`.

  • Corrective republish: `0.11.0`'s published manifest shipped unrewritten `workspace:*` dependency specifiers (the only package of the 41 in the 2026-07-13 train to leak them — the `changeset publish` path skipped the rewrite for this package while `pnpm pack`/`pnpm publish` performs it correctly; mechanism unexplained, so the post-publish registry sweep now greps every published manifest for `workspace:`). `0.11.0` cannot be installed by npm consumers (`EUNSUPPORTEDPROTOCOL`) and is deprecated on the registry. No code changes — dist is byte-identical to `0.11.0`.
v0.11.0patch

26dfa07: Pre-existing test-suite fixes (unrelated to recent feature work): - `high-impact-hero`'s `illustrationHero` variant carried two tags (`brand`, `illustration`) outside the canonical taxonomy declared in `v2-coverage.test.ts`. Fixed to `['editorial', 'playful']`. The `illustrationHero` slug itself is kept camelCase (not renamed to kebab-case) because it shipped in the published `0.5.0` release (2026-05-21) and is stored verbatim in consumer content as a `_variant` field value — renaming would silently break every stored document that already selected it. The kebab-case rule now carries a documented, slug-scoped exception with a deprecation trigger (next content-breaking major version for this package, when a stored-content migration pass is already budgeted). - `blocks-core`'s `test/top20-variants.test.ts` imported `@wabbit/tome-blocks-marketing-starter` and the other bundle packs directly, which a later refactor made unresolvable when it removed blocks-core's devDeps on those packs to break the blocks-core ↔ marketing-starter dependency cycle. Relocated the test to `@wabbit/tome-blocks` (the meta-package that already depends on every pack for exactly this purpose) rather than re-adding the removed devDeps and recreating the cycle. Test-only change; no runtime behavior changed in either package.

  • 26dfa07: Pre-existing test-suite fixes (unrelated to recent feature work): - `high-impact-hero`'s `illustrationHero` variant carried two tags (`brand`, `illustration`) outside the canonical taxonomy declared in `v2-coverage.test.ts`. Fixed to `['editorial', 'playful']`. The `illustrationHero` slug itself is kept camelCase (not renamed to kebab-case) because it shipped in the published `0.5.0` release (2026-05-21) and is stored verbatim in consumer content as a `_variant` field value — renaming would silently break every stored document that already selected it. The kebab-case rule now carries a documented, slug-scoped exception with a deprecation trigger (next content-breaking major version for this package, when a stored-content migration pass is already budgeted). - `blocks-core`'s `test/top20-variants.test.ts` imported `@wabbit/tome-blocks-marketing-starter` and the other bundle packs directly, which a later refactor made unresolvable when it removed blocks-core's devDeps on those packs to break the blocks-core ↔ marketing-starter dependency cycle. Relocated the test to `@wabbit/tome-blocks` (the meta-package that already depends on every pack for exactly this purpose) rather than re-adding the removed devDeps and recreating the cycle. Test-only change; no runtime behavior changed in either package.
  • 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).
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [a5db69f]
  • Updated dependencies [8100b6f]
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-marketing-starter@0.11.0 - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-blocks-catalog-pack@0.11.0 - @wabbit/tome-blocks-lms-pack@0.11.0 - @wabbit/tome-blocks-sc-pack@0.11.0 - @wabbit/tome-blocks-org-pack@0.2.7 - @wabbit/tome-blocks-agency-essentials@0.11.0 - @wabbit/tome-blocks-content-writer@0.11.0 - @wabbit/tome-blocks-editorial-pack@0.11.0 - @wabbit/tome-blocks-extras@0.11.0 - @wabbit/tome-blocks-signal-theme@0.11.0
v0.10.3patch

@wabbit/tome-blocks-catalog-pack@0.10.3

  • @wabbit/tome-blocks-catalog-pack@0.10.3
  • @wabbit/tome-blocks-core@0.10.0
  • @wabbit/tome-blocks-lms-pack@0.10.3
  • @wabbit/tome-blocks-marketing-starter@0.10.0
v0.10.2patch

Updated dependencies [cd59894] - @wabbit/tome-blocks-signal-theme@0.10.2 - @wabbit/tome-blocks-sc-pack@0.10.2 - @wabbit/tome-blocks-editorial-pack@0.10.2

  • Updated dependencies [cd59894] - @wabbit/tome-blocks-signal-theme@0.10.2 - @wabbit/tome-blocks-sc-pack@0.10.2 - @wabbit/tome-blocks-editorial-pack@0.10.2
v0.10.1patch

@wabbit/tome-blocks-catalog-pack@0.10.1

  • @wabbit/tome-blocks-catalog-pack@0.10.1
  • @wabbit/tome-blocks-core@0.10.0
  • @wabbit/tome-blocks-lms-pack@0.10.1
  • @wabbit/tome-blocks-marketing-starter@0.10.0
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-agency-essentials@0.10.0 - @wabbit/tome-blocks-catalog-pack@0.10.0 - @wabbit/tome-blocks-content-writer@0.10.0 - @wabbit/tome-blocks-editorial-pack@0.10.0 - @wabbit/tome-blocks-extras@0.10.0 - @wabbit/tome-blocks-lms-pack@0.10.0 - @wabbit/tome-blocks-marketing-starter@0.10.0 - @wabbit/tome-blocks-sc-pack@0.10.0 - @wabbit/tome-blocks-signal-theme@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-agency-essentials@0.10.0 - @wabbit/tome-blocks-catalog-pack@0.10.0 - @wabbit/tome-blocks-content-writer@0.10.0 - @wabbit/tome-blocks-editorial-pack@0.10.0 - @wabbit/tome-blocks-extras@0.10.0 - @wabbit/tome-blocks-lms-pack@0.10.0 - @wabbit/tome-blocks-marketing-starter@0.10.0 - @wabbit/tome-blocks-sc-pack@0.10.0 - @wabbit/tome-blocks-signal-theme@0.10.0
v0.9.5patch

Updated dependencies - @wabbit/tome-blocks-content-writer@0.9.5 - @wabbit/tome-blocks-marketing-starter@0.9.5 - @wabbit/tome-blocks-extras@0.9.5 - @wabbit/tome-blocks-editorial-pack@0.9.5 - @wabbit/tome-blocks-agency-essentials@0.9.5 - @wabbit/tome-blocks-sc-pack@0.9.5 - @wabbit/tome-blocks-signal-theme@0.9.5

  • Updated dependencies - @wabbit/tome-blocks-content-writer@0.9.5 - @wabbit/tome-blocks-marketing-starter@0.9.5 - @wabbit/tome-blocks-extras@0.9.5 - @wabbit/tome-blocks-editorial-pack@0.9.5 - @wabbit/tome-blocks-agency-essentials@0.9.5 - @wabbit/tome-blocks-sc-pack@0.9.5 - @wabbit/tome-blocks-signal-theme@0.9.5
v0.9.4patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-agency-essentials@0.9.4 - @wabbit/tome-blocks-catalog-pack@0.9.4 - @wabbit/tome-blocks-lms-pack@0.9.4 - @wabbit/tome-blocks-signal-theme@0.9.4 - @wabbit/tome-blocks-marketing-starter@0.9.4 - @wabbit/tome-blocks-content-writer@0.9.4 - @wabbit/tome-blocks-editorial-pack@0.9.4 - @wabbit/tome-blocks-extras@0.9.4 - @wabbit/tome-blocks-sc-pack@0.9.4
v0.9.3patch

@wabbit/tome-blocks-editorial-pack@0.9.3

  • @wabbit/tome-blocks-editorial-pack@0.9.3
  • @wabbit/tome-blocks-signal-theme@0.9.3
v0.9.2patch

Updated dependencies

  • Updated dependencies
  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-signal-theme@0.9.2 - @wabbit/tome-blocks-agency-essentials@0.9.2 - @wabbit/tome-blocks-catalog-pack@0.9.2 - @wabbit/tome-blocks-content-writer@0.9.2 - @wabbit/tome-blocks-editorial-pack@0.9.2 - @wabbit/tome-blocks-extras@0.9.2 - @wabbit/tome-blocks-lms-pack@0.9.2 - @wabbit/tome-blocks-marketing-starter@0.9.2 - @wabbit/tome-blocks-sc-pack@0.9.2
v0.9.1patch

Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1 - @wabbit/tome-blocks-agency-essentials@0.9.1 - @wabbit/tome-blocks-content-writer@0.9.1 - @wabbit/tome-blocks-editorial-pack@0.9.1 - @wabbit/tome-blocks-marketing-starter@0.9.1 - @wabbit/tome-blocks-sc-pack@0.9.1 - @wabbit/tome-blocks-signal-theme@0.9.1

  • Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1 - @wabbit/tome-blocks-agency-essentials@0.9.1 - @wabbit/tome-blocks-content-writer@0.9.1 - @wabbit/tome-blocks-editorial-pack@0.9.1 - @wabbit/tome-blocks-marketing-starter@0.9.1 - @wabbit/tome-blocks-sc-pack@0.9.1 - @wabbit/tome-blocks-signal-theme@0.9.1
v0.9.0patch

Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0 - @wabbit/tome-blocks-marketing-starter@0.9.0 - @wabbit/tome-blocks-content-writer@0.9.0 - @wabbit/tome-blocks-agency-essentials@0.9.0 - @wabbit/tome-blocks-editorial-pack@0.9.0 - @wabbit/tome-blocks-signal-theme@0.9.0 - @wabbit/tome-blocks-catalog-pack@0.9.0 - @wabbit/tome-blocks-lms-pack@0.9.0 - @wabbit/tome-blocks-sc-pack@0.9.0

  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0 - @wabbit/tome-blocks-marketing-starter@0.9.0 - @wabbit/tome-blocks-content-writer@0.9.0 - @wabbit/tome-blocks-agency-essentials@0.9.0 - @wabbit/tome-blocks-editorial-pack@0.9.0 - @wabbit/tome-blocks-signal-theme@0.9.0 - @wabbit/tome-blocks-catalog-pack@0.9.0 - @wabbit/tome-blocks-lms-pack@0.9.0 - @wabbit/tome-blocks-sc-pack@0.9.0
v0.8.0patch

Updated dependencies [249b670]

  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670] - @wabbit/tome-blocks-marketing-starter@0.8.0 - @wabbit/tome-blocks-extras@0.8.0 - @wabbit/tome-blocks-signal-theme@0.8.0 - @wabbit/tome-blocks-lms-pack@0.8.0 - @wabbit/tome-blocks-catalog-pack@0.8.0 - @wabbit/tome-blocks-sc-pack@0.8.0 - @wabbit/tome-blocks-core@0.8.0 - @wabbit/tome-blocks-agency-essentials@0.8.0 - @wabbit/tome-blocks-content-writer@0.8.0 - @wabbit/tome-blocks-editorial-pack@0.8.0
v0.7.0patch

Updated dependencies [28802fa]

  • Updated dependencies [28802fa]
  • Updated dependencies [66c611c]
  • Updated dependencies [8958d41] - @wabbit/tome-blocks-editorial-pack@0.7.0 - @wabbit/tome-blocks-content-writer@0.7.0 - @wabbit/tome-blocks-extras@0.7.0 - @wabbit/tome-blocks-marketing-starter@0.7.0 - @wabbit/tome-blocks-core@0.7.0 - @wabbit/tome-blocks-signal-theme@0.7.0 - @wabbit/tome-blocks-sc-pack@0.7.0 - @wabbit/tome-blocks-agency-essentials@0.7.0 - @wabbit/tome-blocks-catalog-pack@0.7.0 - @wabbit/tome-blocks-lms-pack@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-catalog-pack@0.6.2 - @wabbit/tome-blocks-lms-pack@0.6.2 - @wabbit/tome-blocks-agency-essentials@0.6.2 - @wabbit/tome-blocks-content-writer@0.6.2 - @wabbit/tome-blocks-editorial-pack@0.6.2 - @wabbit/tome-blocks-extras@0.6.2 - @wabbit/tome-blocks-marketing-starter@0.6.2 - @wabbit/tome-blocks-sc-pack@0.6.2 - @wabbit/tome-blocks-signal-theme@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-catalog-pack@0.6.2 - @wabbit/tome-blocks-lms-pack@0.6.2 - @wabbit/tome-blocks-agency-essentials@0.6.2 - @wabbit/tome-blocks-content-writer@0.6.2 - @wabbit/tome-blocks-editorial-pack@0.6.2 - @wabbit/tome-blocks-extras@0.6.2 - @wabbit/tome-blocks-marketing-starter@0.6.2 - @wabbit/tome-blocks-sc-pack@0.6.2 - @wabbit/tome-blocks-signal-theme@0.6.2
v0.6.1patch

Updated dependencies [f37fa00] - @wabbit/tome-blocks-agency-essentials@0.6.1 - @wabbit/tome-blocks-marketing-starter@0.6.1 - @wabbit/tome-blocks-signal-theme@0.6.1 - @wabbit/tome-blocks-core@0.5.9

  • Updated dependencies [f37fa00] - @wabbit/tome-blocks-agency-essentials@0.6.1 - @wabbit/tome-blocks-marketing-starter@0.6.1 - @wabbit/tome-blocks-signal-theme@0.6.1 - @wabbit/tome-blocks-core@0.5.9
v0.6.0patch

Updated dependencies [dfd8a78] - @wabbit/tome-blocks-editorial-pack@0.6.0 - @wabbit/tome-blocks-core@0.5.9

  • Updated dependencies [dfd8a78] - @wabbit/tome-blocks-editorial-pack@0.6.0 - @wabbit/tome-blocks-core@0.5.9
v0.5.11patch

@wabbit/tome-blocks-editorial-pack@0.5.11

  • @wabbit/tome-blocks-editorial-pack@0.5.11
  • @wabbit/tome-blocks-core@0.5.9
v0.5.10patch

Updated dependencies [497409b] - @wabbit/tome-blocks-content-writer@0.5.10 - @wabbit/tome-blocks-core@0.5.9

  • Updated dependencies [497409b] - @wabbit/tome-blocks-content-writer@0.5.10 - @wabbit/tome-blocks-core@0.5.9
v0.5.9patch

Updated dependencies [8947ff1] - @wabbit/tome-blocks-marketing-starter@0.5.9 - @wabbit/tome-blocks-core@0.5.9 - @wabbit/tome-blocks-catalog-pack@0.5.9 - @wabbit/tome-blocks-lms-pack@0.5.9 - @wabbit/tome-blocks-editorial-pack@0.5.9 - @wabbit/tome-blocks-agency-essentials@0.5.9 - @wabbit/tome-blocks-content-writer@0.5.9 - @wabbit/tome-blocks-extras@0.5.9 - @wabbit/tome-blocks-sc-pack@0.5.9 - @wabbit/tome-blocks-signal-theme@0.5.9

  • Updated dependencies [8947ff1] - @wabbit/tome-blocks-marketing-starter@0.5.9 - @wabbit/tome-blocks-core@0.5.9 - @wabbit/tome-blocks-catalog-pack@0.5.9 - @wabbit/tome-blocks-lms-pack@0.5.9 - @wabbit/tome-blocks-editorial-pack@0.5.9 - @wabbit/tome-blocks-agency-essentials@0.5.9 - @wabbit/tome-blocks-content-writer@0.5.9 - @wabbit/tome-blocks-extras@0.5.9 - @wabbit/tome-blocks-sc-pack@0.5.9 - @wabbit/tome-blocks-signal-theme@0.5.9
v0.5.8patch

Updated dependencies [3f0c503] - @wabbit/tome-blocks-editorial-pack@0.5.8 - @wabbit/tome-blocks-core@0.5.7

  • Updated dependencies [3f0c503] - @wabbit/tome-blocks-editorial-pack@0.5.8 - @wabbit/tome-blocks-core@0.5.7
v0.5.7patch

@wabbit/tome-blocks-catalog-pack@0.5.7

  • @wabbit/tome-blocks-catalog-pack@0.5.7
  • @wabbit/tome-blocks-core@0.5.7
  • @wabbit/tome-blocks-lms-pack@0.5.7
  • @wabbit/tome-blocks-agency-essentials@0.5.7
  • @wabbit/tome-blocks-content-writer@0.5.7
  • @wabbit/tome-blocks-editorial-pack@0.5.7
  • @wabbit/tome-blocks-extras@0.5.7
  • @wabbit/tome-blocks-marketing-starter@0.5.7
  • @wabbit/tome-blocks-sc-pack@0.5.7
  • @wabbit/tome-blocks-signal-theme@0.5.7
v0.5.0minor

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

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

Updated dependencies [97d2311] - @wabbit/tome-blocks-content-writer@0.4.4 - @wabbit/tome-blocks-core@0.4.3

  • Updated dependencies [97d2311] - @wabbit/tome-blocks-content-writer@0.4.4 - @wabbit/tome-blocks-core@0.4.3
v0.4.3patch

**Fix: list all 10 sibling block packages in tsup `external` array.** The meta package's `tsup.config.ts` was templated from `blocks-marketing-starter`, whose narrower `external` list (`['@wabbit/tome-blocks-core', '@wabbit/tome-blocks-extras']`) is correct for that pack's import surface but wrong here. The meta package re-exports from all 10 sibling packs so all 10 must be externalized. Without all 10 externalized, rollup-plugin-dts (used by tsup's `dts: true`) attempts to bundle each un-externalized pack's `.d.ts` surface into the meta package's dist. In CI, dependent packs may not have a built `dist/` yet when this resolution runs, producing "Cannot find module '@wabbit/tome-blocks-X'" errors during the DTS step. No source or runtime changes; pure build-config fix.

  • **Fix: list all 10 sibling block packages in tsup `external` array.** The meta package's `tsup.config.ts` was templated from `blocks-marketing-starter`, whose narrower `external` list (`['@wabbit/tome-blocks-core', '@wabbit/tome-blocks-extras']`) is correct for that pack's import surface but wrong here. The meta package re-exports from all 10 sibling packs so all 10 must be externalized. Without all 10 externalized, rollup-plugin-dts (used by tsup's `dts: true`) attempts to bundle each un-externalized pack's `.d.ts` surface into the meta package's dist. In CI, dependent packs may not have a built `dist/` yet when this resolution runs, producing "Cannot find module '@wabbit/tome-blocks-X'" errors during the DTS step. No source or runtime changes; pure build-config fix.
v0.4.2patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-lms-pack@0.4.2 - @wabbit/tome-blocks-catalog-pack@0.4.2 - @wabbit/tome-blocks-core@0.4.2 - @wabbit/tome-blocks-agency-essentials@0.4.2 - @wabbit/tome-blocks-content-writer@0.4.2 - @wabbit/tome-blocks-editorial-pack@0.4.2 - @wabbit/tome-blocks-marketing-starter@0.4.2 - @wabbit/tome-blocks-sc-pack@0.4.2 - @wabbit/tome-blocks-signal-theme@0.4.2

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-lms-pack@0.4.2 - @wabbit/tome-blocks-catalog-pack@0.4.2 - @wabbit/tome-blocks-core@0.4.2 - @wabbit/tome-blocks-agency-essentials@0.4.2 - @wabbit/tome-blocks-content-writer@0.4.2 - @wabbit/tome-blocks-editorial-pack@0.4.2 - @wabbit/tome-blocks-marketing-starter@0.4.2 - @wabbit/tome-blocks-sc-pack@0.4.2 - @wabbit/tome-blocks-signal-theme@0.4.2
v0.4.1patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-agency-essentials@0.4.1 - @wabbit/tome-blocks-content-writer@0.4.1 - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-editorial-pack@0.4.1 - @wabbit/tome-blocks-marketing-starter@0.4.1 - @wabbit/tome-blocks-sc-pack@0.4.1 - @wabbit/tome-blocks-signal-theme@0.4.1

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-agency-essentials@0.4.1 - @wabbit/tome-blocks-content-writer@0.4.1 - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-editorial-pack@0.4.1 - @wabbit/tome-blocks-marketing-starter@0.4.1 - @wabbit/tome-blocks-sc-pack@0.4.1 - @wabbit/tome-blocks-signal-theme@0.4.1
v0.4.0patch

Updated dependencies [b76f684]

  • Updated dependencies [b76f684]
  • Updated dependencies [90a694d] - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-extras@0.4.0 - @wabbit/tome-blocks-agency-essentials@0.4.0 - @wabbit/tome-blocks-catalog-pack@0.4.0 - @wabbit/tome-blocks-content-writer@0.4.0 - @wabbit/tome-blocks-editorial-pack@0.4.0 - @wabbit/tome-blocks-lms-pack@0.4.0 - @wabbit/tome-blocks-marketing-starter@0.4.0 - @wabbit/tome-blocks-sc-pack@0.4.0 - @wabbit/tome-blocks-signal-theme@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0 - @wabbit/tome-blocks-marketing-starter@0.3.0 - @wabbit/tome-blocks-content-writer@0.3.0 - @wabbit/tome-blocks-agency-essentials@0.3.0 - @wabbit/tome-blocks-editorial-pack@0.3.0 - @wabbit/tome-blocks-signal-theme@0.3.0 - @wabbit/tome-blocks-lms-pack@0.3.0 - @wabbit/tome-blocks-catalog-pack@0.3.0 - @wabbit/tome-blocks-sc-pack@0.3.0 - @wabbit/tome-blocks-extras@0.3.0

Blocks House

v0.10.3
v0.10.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.10.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.10.1patch

8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The `imageDisplay` group's `aspectRatio` was a select whose values (`16:9`, `4:3`, ...) contain a colon. Payload turns each select option into a GraphQL enum name and does not strip `:`, so the schema build threw and `/api/graphql` returned 500 on any site using a block with an `imageDisplay` group. `aspectRatio` is now a text field with the same default (`auto`) that accepts exactly the same values (`auto`, `16:9`, `4:3`, `1:1`, `3:4`, `21:9`, or empty); existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it. `validateAspectRatio` and `ASPECT_RATIO_VALUES` are exported next to `ASPECT_RATIO_OPTIONS`.

  • 8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The `imageDisplay` group's `aspectRatio` was a select whose values (`16:9`, `4:3`, ...) contain a colon. Payload turns each select option into a GraphQL enum name and does not strip `:`, so the schema build threw and `/api/graphql` returned 500 on any site using a block with an `imageDisplay` group. `aspectRatio` is now a text field with the same default (`auto`) that accepts exactly the same values (`auto`, `16:9`, `4:3`, `1:1`, `3:4`, `21:9`, or empty); existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it. `validateAspectRatio` and `ASPECT_RATIO_VALUES` are exported next to `ASPECT_RATIO_OPTIONS`.
v0.10.0minor

f4a08ca: **BREAKING:** these packs now need `@wabbit/tome-blocks-core` 0.35.0 or later, because they read the wrapper's new `data-block-placement` instead of its inline style text. **Migration:** upgrade `@wabbit/tome-blocks-core` to 0.35.0 or later alongside these packs. A site that renders its own block wrapper (with `data-tome-block-wrapper`) on a span other than `1 / -1` should add `data-block-placement={blockPlacementFor(span)}` to it; a wrapper without the attribute is read as full width. - `@wabbit/tome-blocks-local-pack`: in Safari, the price list's teaser list and the logistics block's content touched the viewport edges once the page had scrolled. Their side padding depended on matching the wrapper's `style` text, which WebKit rewrites as `grid-column-start`/`grid-column-end` longhands as soon as the scroll reveal writes to it. The padding rule now reads `data-block-placement`. Chromium and Firefox render as before. - `@wabbit/tome-blocks-house`: `LaneGrid` decides whether to subgrid its block wrapper (full row, content column, or its own tracks for any other span) from `data-block-placement`, so the decision no longer changes in Safari after the scroll reveal runs. - `@wabbit/tome-blocks-marketing-starter`: `exhibit`'s nested block wrappers carry `data-block-placement`, matching `RenderBlocks`.

  • f4a08ca: **BREAKING:** these packs now need `@wabbit/tome-blocks-core` 0.35.0 or later, because they read the wrapper's new `data-block-placement` instead of its inline style text. **Migration:** upgrade `@wabbit/tome-blocks-core` to 0.35.0 or later alongside these packs. A site that renders its own block wrapper (with `data-tome-block-wrapper`) on a span other than `1 / -1` should add `data-block-placement={blockPlacementFor(span)}` to it; a wrapper without the attribute is read as full width. - `@wabbit/tome-blocks-local-pack`: in Safari, the price list's teaser list and the logistics block's content touched the viewport edges once the page had scrolled. Their side padding depended on matching the wrapper's `style` text, which WebKit rewrites as `grid-column-start`/`grid-column-end` longhands as soon as the scroll reveal writes to it. The padding rule now reads `data-block-placement`. Chromium and Firefox render as before. - `@wabbit/tome-blocks-house`: `LaneGrid` decides whether to subgrid its block wrapper (full row, content column, or its own tracks for any other span) from `data-block-placement`, so the decision no longer changes in Safari after the scroll reveal runs. - `@wabbit/tome-blocks-marketing-starter`: `exhibit`'s nested block wrappers carry `data-block-placement`, matching `RenderBlocks`.
v0.9.1patch

d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.

  • d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.
v0.9.0minor

4fd45ea: `animateInView` and `inkIn` now return the GSAP tween they build, and a new `killTween` helper stops it. Both return `undefined` when they build nothing (no trigger element, or reduced motion for `animateInView`). `killTween(tween)` kills the tween's ScrollTrigger and then the tween, so a block that follows the motion preference or unmounts can clean up what it started. Callers that ignore the return value are unaffected.

  • 4fd45ea: `animateInView` and `inkIn` now return the GSAP tween they build, and a new `killTween` helper stops it. Both return `undefined` when they build nothing (no trigger element, or reduced motion for `animateInView`). `killTween(tween)` kills the tween's ScrollTrigger and then the tween, so a block that follows the motion preference or unmounts can clean up what it started. Callers that ignore the return value are unaffected.
v0.8.5patch

eee4d87: Block CTAs gain one editorial register a site can set once per block, and Full-Bleed Interstitial's CTA moves to the start of the content column. **The editorial CTA register.** Seven tokens name the editorial CTA's voice: `--tome-house-cta-editorial-family`, `-size`, `-weight`, `-tracking`, `-transform`, `-margin` and `-color`. The `block-cta-editorial` Sass mixin reads them, each falling back to the value it always had (mono, `--tome-text-xs`, 600, `--tome-house-tracking-cta-editorial`, uppercase, `--tome-space-md`, `inherit`), and a new plain-CSS class, `.tome-house-cta-editorial` in `styles/cta-editorial.css`, carries the same rule (with the underline swipe) for a consumer without Sass. The block CTAs in this package, the dossier pack and the cinema pack read the same tokens, each falling back to its own block's current value, so nothing changes until a site sets them. `styles/tokens.css` does not declare them, on purpose: a `:root` value would replace every block's own fallback at once. **Full-Bleed Interstitial.** The line's box reads `--tome-house-interstitial-max-width` (default `min(100%, 60rem)`) and `--tome-house-interstitial-pad-inline` (default `var(--tome-space-lg)`); a site that runs the line across its content column sets `100%` and `var(--tome-grid-padding)`. Its CTA reads the register, falling back to what it had (no type of its own, the md top margin). Visible change on a default render: - **Full-Bleed Interstitial's CTA** now sits at the start of the content column, below the centered line and caption, instead of centered under them: its left edge is at the band's side gutter (`--tome-grid-padding` on tome-ui, `--tome-space-lg` without it), or on `--tome-house-cta-cols` when a site sets that. A label that wraps aligns to the start. The band root is now a three-track grid (gutter, content, gutter) instead of a flex column; the line and caption keep their centering, width, padding and vertical centering, and the band, scene image and caption are unchanged. - **Its link color** is now declared on the CTA itself as `var(--tome-color-primary)`, the color tome-ui's base stylesheet gives every link, so on a tome-ui site it is unchanged. On a site whose own element-level `a { color }` rule colored it differently, the CTA now takes `--tome-color-primary` unless the site sets `--tome-house-cta-editorial-color`; a link rule with a class or `:root` in it still wins as before.

  • eee4d87: Block CTAs gain one editorial register a site can set once per block, and Full-Bleed Interstitial's CTA moves to the start of the content column. **The editorial CTA register.** Seven tokens name the editorial CTA's voice: `--tome-house-cta-editorial-family`, `-size`, `-weight`, `-tracking`, `-transform`, `-margin` and `-color`. The `block-cta-editorial` Sass mixin reads them, each falling back to the value it always had (mono, `--tome-text-xs`, 600, `--tome-house-tracking-cta-editorial`, uppercase, `--tome-space-md`, `inherit`), and a new plain-CSS class, `.tome-house-cta-editorial` in `styles/cta-editorial.css`, carries the same rule (with the underline swipe) for a consumer without Sass. The block CTAs in this package, the dossier pack and the cinema pack read the same tokens, each falling back to its own block's current value, so nothing changes until a site sets them. `styles/tokens.css` does not declare them, on purpose: a `:root` value would replace every block's own fallback at once. **Full-Bleed Interstitial.** The line's box reads `--tome-house-interstitial-max-width` (default `min(100%, 60rem)`) and `--tome-house-interstitial-pad-inline` (default `var(--tome-space-lg)`); a site that runs the line across its content column sets `100%` and `var(--tome-grid-padding)`. Its CTA reads the register, falling back to what it had (no type of its own, the md top margin). Visible change on a default render: - **Full-Bleed Interstitial's CTA** now sits at the start of the content column, below the centered line and caption, instead of centered under them: its left edge is at the band's side gutter (`--tome-grid-padding` on tome-ui, `--tome-space-lg` without it), or on `--tome-house-cta-cols` when a site sets that. A label that wraps aligns to the start. The band root is now a three-track grid (gutter, content, gutter) instead of a flex column; the line and caption keep their centering, width, padding and vertical centering, and the band, scene image and caption are unchanged. - **Its link color** is now declared on the CTA itself as `var(--tome-color-primary)`, the color tome-ui's base stylesheet gives every link, so on a tome-ui site it is unchanged. On a site whose own element-level `a { color }` rule colored it differently, the CTA now takes `--tome-color-primary` unless the site sets `--tome-house-cta-editorial-color`; a link rule with a class or `:root` in it still wins as before.
v0.8.4patch

59d51da: The band system gains an optional emphasis pair, gap sizes read tokens, `fillMediaOptions` moves here for every house pack, and the interstitial's scene fills under a wrapping media adapter. Visible changes on a default render: - **Full-Bleed Interstitial, band set per theme.** A band stored per light and dark theme now paints under tome-ui's `data-theme`, each falling back to the base band and then the solid-dark default. Before, only the base band painted. - **Full-Bleed Interstitial, scene image.** The image passes the media adapter `imgClassName`, `fill: true` and `sizes: '100vw'`, so an adapter that wraps its image fills the band with a cover crop that follows the Image Display fit and focus. The built-in adapter renders the same `<img>` with a second class and a `sizes` attribute; it looks the same. - **Full-Bleed Interstitial, scene label.** It reads `--tome-type-size-xxs`, the step the packs' small labels share, instead of `--tome-text-xs`. On tome-ui's default scale the two are the same size; a site that sets the two steps apart sees the label take the smaller one. No other change until a site sets a token or registers a value: - **Emphasis pair.** `resolveBackground` emits `--blk-emph-bg` and `--blk-emph-fg` (and their `-light` / `-dark` forms) for a card meant to stand out against the band. Every built-in band emits both as `initial`, so blocks draw their own fallback. `registerBackgroundAppearances` and `overrideBackgroundAppearance` accept optional `emph` and `onEmph` keys; a definition without them emits `initial`. - **Gap tokens.** `GAP_CSS_VALUES` (and so `gapCssValue`) read `--tome-house-gap-tight`, `--tome-house-gap-standard` and `--tome-house-gap-loose` first, then `--tome-space-sm`, `--tome-space-lg` and `--tome-space-2xl` as before, so a site with a root font size other than 16px can pin the steps to 8, 16 and 32px. - **`fillMediaOptions`**, `FULL_BLEED_SIZES` and the `FillMediaInput` type are exported from `./components`: the options for an image that fills a box the block sizes itself (`className`, `imgClassName`, `fill: true`, `sizes`, `alt`). - **Reveal group planner.** `planRevealGroups`, `staggerForSteps` and the `RevealItem` / `RevealGroupPlan` types are exported from `./motion`: the stagger rules every pack's opt-in entrance reveal plans its groups with (one home, so the packs do not each carry a copy). The dossier pack re-exports them from its own `revealPlan`.

  • 59d51da: The band system gains an optional emphasis pair, gap sizes read tokens, `fillMediaOptions` moves here for every house pack, and the interstitial's scene fills under a wrapping media adapter. Visible changes on a default render: - **Full-Bleed Interstitial, band set per theme.** A band stored per light and dark theme now paints under tome-ui's `data-theme`, each falling back to the base band and then the solid-dark default. Before, only the base band painted. - **Full-Bleed Interstitial, scene image.** The image passes the media adapter `imgClassName`, `fill: true` and `sizes: '100vw'`, so an adapter that wraps its image fills the band with a cover crop that follows the Image Display fit and focus. The built-in adapter renders the same `<img>` with a second class and a `sizes` attribute; it looks the same. - **Full-Bleed Interstitial, scene label.** It reads `--tome-type-size-xxs`, the step the packs' small labels share, instead of `--tome-text-xs`. On tome-ui's default scale the two are the same size; a site that sets the two steps apart sees the label take the smaller one. No other change until a site sets a token or registers a value: - **Emphasis pair.** `resolveBackground` emits `--blk-emph-bg` and `--blk-emph-fg` (and their `-light` / `-dark` forms) for a card meant to stand out against the band. Every built-in band emits both as `initial`, so blocks draw their own fallback. `registerBackgroundAppearances` and `overrideBackgroundAppearance` accept optional `emph` and `onEmph` keys; a definition without them emits `initial`. - **Gap tokens.** `GAP_CSS_VALUES` (and so `gapCssValue`) read `--tome-house-gap-tight`, `--tome-house-gap-standard` and `--tome-house-gap-loose` first, then `--tome-space-sm`, `--tome-space-lg` and `--tome-space-2xl` as before, so a site with a root font size other than 16px can pin the steps to 8, 16 and 32px. - **`fillMediaOptions`**, `FULL_BLEED_SIZES` and the `FillMediaInput` type are exported from `./components`: the options for an image that fills a box the block sizes itself (`className`, `imgClassName`, `fill: true`, `sizes`, `alt`). - **Reveal group planner.** `planRevealGroups`, `staggerForSteps` and the `RevealItem` / `RevealGroupPlan` types are exported from `./motion`: the stagger rules every pack's opt-in entrance reveal plans its groups with (one home, so the packs do not each carry a copy). The dossier pack re-exports them from its own `revealPlan`.
v0.8.3patch

cc5d280: `animateInView` takes GSAP's function form for `stagger` and a new optional `clearProps`, which removes inline styles from each target once its tween completes. - `stagger` now accepts any `gsap.NumberValue`, so several targets can move on the same step. The value was always passed to GSAP unchanged; only the option's type was narrower. Calls that pass a number are unaffected. - `clearProps` (a GSAP comma list such as `'opacity,visibility,transform'`) is forwarded to the tween, so each target drops those inline properties when its own tween completes and renders from its stylesheet again. Omitted, nothing is cleared, exactly as before.

  • cc5d280: `animateInView` takes GSAP's function form for `stagger` and a new optional `clearProps`, which removes inline styles from each target once its tween completes. - `stagger` now accepts any `gsap.NumberValue`, so several targets can move on the same step. The value was always passed to GSAP unchanged; only the option's type was narrower. Calls that pass a number are unaffected. - `clearProps` (a GSAP comma list such as `'opacity,visibility,transform'`) is forwarded to the tween, so each target drops those inline properties when its own tween completes and renders from its stylesheet again. Omitted, nothing is cleared, exactly as before.
v0.8.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.8.1patch

c8f15cc: `LaneGrid` gains a seventh slot, `lead`, running from the prose track's left edge to the end of the reading column. Additive: the six existing slots keep their lanes, and a block that does not use `lead` renders exactly as before. From 1024px `lead` spans `prose-start / reading-end`, for an intro that hangs on the prose line but needs more width than the prose track; below 1024px it stacks on the content column like every other slot. It is not capped, because rows and figures sit in it too, so text inside it should carry its own measure.

  • c8f15cc: `LaneGrid` gains a seventh slot, `lead`, running from the prose track's left edge to the end of the reading column. Additive: the six existing slots keep their lanes, and a block that does not use `lead` renders exactly as before. From 1024px `lead` spans `prose-start / reading-end`, for an intro that hangs on the prose line but needs more width than the prose track; below 1024px it stacks on the content column like every other slot. It is not capped, because rows and figures sit in it too, so text inside it should carry its own measure.
v0.8.0minor

c4dba45: Sites can now fit house-pack blocks to their own stored data and heading voice: extra background appearances, a site CTA resolver, heading-voice tokens, two more text sizes and a lane grid. Additive; nothing renders differently until a site uses one of these. - **Background appearance registry.** `registerBackgroundAppearances()` adds band values with their full token set (surface, text, companion inks, tone), so a stored value the package does not know paints a band instead of rendering transparent. Registering a built-in name throws. `createBlockBackgroundGroup({ extraAppearances })` and `withSharedBackground(block, { extraAppearances })` list them in the admin, with `registeredBackgroundAppearanceOptions()`. An unknown, unregistered value still renders no background and, in development, logs one warning naming it. - **Restyling a built-in band.** `overrideBackgroundAppearance(name, definition)` replaces the tokens of one built-in appearance (not `inherit`) for a site whose stored value has a built-in's name but its own inks; `null` removes the override. It is a separate call so it cannot happen by accident, and it changes that band for every pack block on the site. Option labels follow an optional `label` on the override. - **CTA resolver registration.** `registerCtaResolver()` lets a site with its own stored link shape turn CTAs into links for `BlockCta` and `resolveCtaHref`. Order: a per-call `resolveReference`, then the registered resolver, then the built-in default. Returning `undefined` falls through; `null` means no link. `withResolvedCtas` accepts an optional `references` detector (`CtaReferenceDetector`) so a site with its own stored link shape also gets its depth-0 links looked up: one public-access query per collection per block, the stored block never mutated, the built-in detector still running. - **Heading-voice tokens.** Twelve `--tome-house-heading-*` custom properties (family, weight, style, transform, tracking, leading, and the same for the accent phrase), with the `heading-voice` Sass mixin in `styles/heading-voice.scss`. House packs adopt them in later releases. - **Text sizes.** The chrome `textSize` control adds `xxs` (on `--tome-text-xs`) and `xxxl` (on `--tome-text-h1`, 40 to 64px, a display size). - **`LaneGrid`.** A server-safe layout component, with `LaneSlot` and `laneSlotClass()`, that gives a block the page grid's named reading lanes on any site: it subgrids tome-ui's page grid when it sits in it and lays out the same tracks itself otherwise. - **`LaneGrid` and block wrappers.** Automatic subgrid now reads the wrapper's column span: a full-row or content-column span subgrids, a bleeding grid in a content-column wrapper takes the content column, and a narrower span gets own tracks across the wrapper. Under `disableContainer` with no grid parent, pass `parent="none"`.

  • c4dba45: Sites can now fit house-pack blocks to their own stored data and heading voice: extra background appearances, a site CTA resolver, heading-voice tokens, two more text sizes and a lane grid. Additive; nothing renders differently until a site uses one of these. - **Background appearance registry.** `registerBackgroundAppearances()` adds band values with their full token set (surface, text, companion inks, tone), so a stored value the package does not know paints a band instead of rendering transparent. Registering a built-in name throws. `createBlockBackgroundGroup({ extraAppearances })` and `withSharedBackground(block, { extraAppearances })` list them in the admin, with `registeredBackgroundAppearanceOptions()`. An unknown, unregistered value still renders no background and, in development, logs one warning naming it. - **Restyling a built-in band.** `overrideBackgroundAppearance(name, definition)` replaces the tokens of one built-in appearance (not `inherit`) for a site whose stored value has a built-in's name but its own inks; `null` removes the override. It is a separate call so it cannot happen by accident, and it changes that band for every pack block on the site. Option labels follow an optional `label` on the override. - **CTA resolver registration.** `registerCtaResolver()` lets a site with its own stored link shape turn CTAs into links for `BlockCta` and `resolveCtaHref`. Order: a per-call `resolveReference`, then the registered resolver, then the built-in default. Returning `undefined` falls through; `null` means no link. `withResolvedCtas` accepts an optional `references` detector (`CtaReferenceDetector`) so a site with its own stored link shape also gets its depth-0 links looked up: one public-access query per collection per block, the stored block never mutated, the built-in detector still running. - **Heading-voice tokens.** Twelve `--tome-house-heading-*` custom properties (family, weight, style, transform, tracking, leading, and the same for the accent phrase), with the `heading-voice` Sass mixin in `styles/heading-voice.scss`. House packs adopt them in later releases. - **Text sizes.** The chrome `textSize` control adds `xxs` (on `--tome-text-xs`) and `xxxl` (on `--tome-text-h1`, 40 to 64px, a display size). - **`LaneGrid`.** A server-safe layout component, with `LaneSlot` and `laneSlotClass()`, that gives a block the page grid's named reading lanes on any site: it subgrids tome-ui's page grid when it sits in it and lays out the same tracks itself otherwise. - **`LaneGrid` and block wrappers.** Automatic subgrid now reads the wrapper's column span: a full-row or content-column span subgrids, a bleeding grid in a content-column wrapper takes the content column, and a narrower span gets own tracks across the wrapper. Under `disableContainer` with no grid parent, pass `parent="none"`.
v0.7.0minor

6364d79: Block call-to-action buttons that link to an internal page now render a link, and a new server entry fills in the page slugs on depth-0 reads. `resolveCtaHref` and `BlockCta` fall back to a stock default when no resolver is passed: a populated `pages` document maps to `/{slug}` (`home` to `/`) and other collections to `/{collection}/{slug}`. A resolver passed in still decides alone. A reference that cannot be resolved renders no link. New `@wabbit/tome-blocks-house/server` export `withResolvedCtas(renderers, { getPayload, defaultCollection? })` wraps any pack's renderers map. For a page read at depth 0, where a CTA reference is a bare id with no slug, it looks the targets up (one query per collection, public read access only, so unpublished or restricted pages stay unlinked) and renders the block with the slugs filled in. The stored block is not changed, and a block with no bare-id CTA renders untouched. The package now depends on `server-only`. In development, a CTA dropped because its reference is a bare id or has no slug logs one console warning per reference, naming the two fixes. Nothing is logged in production.

  • 6364d79: Block call-to-action buttons that link to an internal page now render a link, and a new server entry fills in the page slugs on depth-0 reads. `resolveCtaHref` and `BlockCta` fall back to a stock default when no resolver is passed: a populated `pages` document maps to `/{slug}` (`home` to `/`) and other collections to `/{collection}/{slug}`. A resolver passed in still decides alone. A reference that cannot be resolved renders no link. New `@wabbit/tome-blocks-house/server` export `withResolvedCtas(renderers, { getPayload, defaultCollection? })` wraps any pack's renderers map. For a page read at depth 0, where a CTA reference is a bare id with no slug, it looks the targets up (one query per collection, public read access only, so unpublished or restricted pages stay unlinked) and renders the block with the slugs filled in. The stored block is not changed, and a block with no bare-id CTA renders untouched. The package now depends on `server-only`. In development, a CTA dropped because its reference is a bare id or has no slug logs one console warning per reference, naming the two fixes. Nothing is logged in production.
v0.6.0minor

868c087: A new `LoopVideoControl` in `@wabbit/tome-blocks-house/video` gives a looping background video a visible pause and play button. It is a presentational client component: the block owns the paused state and stops calling `play()` while it is set. The button is named for the action ("Pause video" / "Play video", overridable with `pauseLabel` / `playLabel`), exposes `aria-pressed`, has a 44px target, and sits in the top corner of its positioned parent. `LoopVideoControlProps` is exported with it.

  • 868c087: A new `LoopVideoControl` in `@wabbit/tome-blocks-house/video` gives a looping background video a visible pause and play button. It is a presentational client component: the block owns the paused state and stops calling `play()` while it is set. The button is named for the action ("Pause video" / "Play video", overridable with `pauseLabel` / `playLabel`), exposes `aria-pressed`, has a 44px target, and sits in the top corner of its positioned parent. `LoopVideoControlProps` is exported with it.
  • 58655f4: The bundle description no longer names a specific site, and reads as a neutral description of the shared primitives.
v0.5.2patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.5.1patch

8c84e70: `resolveBackground` now pairs the theme-invariant appearances with fixed inks. Solid Dark and Inverse also emit `--blk-fg-muted`, `--blk-accent-ink`, `--blk-on-accent-ink` and `--blk-primary-ink`, read from tome-ui's fixed-surface ink tokens, so a solid-dark band no longer shows the light theme's dark accent on near-black (2.84:1 measured). Every other appearance sets those four to `initial`, so blocks fall back to their theme tokens and render exactly as before, and a theme-relative band nested inside a fixed one resets them. Section Strip's counter and the Full-Bleed Interstitial accent read the new accent ink. Without the new tome-ui tokens the companions fall back to the plain on-surface ink, which stays readable.

  • 8c84e70: `resolveBackground` now pairs the theme-invariant appearances with fixed inks. Solid Dark and Inverse also emit `--blk-fg-muted`, `--blk-accent-ink`, `--blk-on-accent-ink` and `--blk-primary-ink`, read from tome-ui's fixed-surface ink tokens, so a solid-dark band no longer shows the light theme's dark accent on near-black (2.84:1 measured). Every other appearance sets those four to `initial`, so blocks fall back to their theme tokens and render exactly as before, and a theme-relative band nested inside a fixed one resets them. Section Strip's counter and the Full-Bleed Interstitial accent read the new accent ink. Without the new tome-ui tokens the companions fall back to the plain on-surface ink, which stays readable.
  • 5e3e6e1: Scroll reveals no longer hide content from screen readers or strand it invisible. `animateInView` now tweens opacity only (it used GSAP `autoAlpha`, which wrote `visibility: hidden`), reveals content that is already on screen or sits too near the end of the page for its trigger to fire, and skips all motion under `prefers-reduced-motion`. New `RevealGate` component (`@wabbit/tome-blocks-house/reveal-gate`, also from `./components`): render it once in the root layout's `<head>` so reveal-pending content is visible without JavaScript and hidden by opacity only once the reveal script is running. Add `suppressHydrationWarning` to `<html>`.
v0.5.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 3 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - EmbedFrame (a client component) renders its stylesheet itself; `<BlockStyles>` works in client components since blocks-core 0.22.0. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 3 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - EmbedFrame (a client component) renders its stylesheet itself; `<BlockStyles>` works in client components since blocks-core 0.22.0. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.4.2patch

68cd193: README explains why the bundle descriptor's `tier` is `free` while the licensing tier is `substrate`.

  • 68cd193: README explains why the bundle descriptor's `tier` is `free` while the licensing tier is `substrate`.
v0.4.1patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.
v0.4.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.3.0minor

a2f2dfa: cinema-pack's block configs now load under plain Node — no bundler, no CSS loader. Eight of cinema-pack's block configs (`src/blocks/*/index.ts`) imported `videoSourceFields` from `@wabbit/tome-blocks-house/video`. That entry is a barrel that also re-exports the `'use client'` `EmbedFrame` and its CSS Module. Next.js resolves that CSS; plain Node does not. So any script that loaded cinema-pack's main entry outside a bundler (a Payload CLI command, a seed, a type generator) failed with `ERR_UNKNOWN_FILE_EXTENSION ".css"` before a single block registered. - **blocks-house (minor):** new `./video/fields` export for `videoSourceFields` and its resolvers. It points at the module the package already built and has no CSS on its import graph. `./video` is unchanged, so existing imports keep working. - **cinema-pack (patch):** the eight block configs import from `@wabbit/tome-blocks-house/video/fields`. Render components still use `./video`, because they need `EmbedFrame` and `useHlsVideo`. Verified against the rebuilt dist: importing `@wabbit/tome-blocks-cinema-pack` and calling `register()` under bare Node now registers all 13 blocks. The same import failed with the CSS error on `main`. cinema-pack's smoke test no longer needs a CSS stub loader, and the stub is removed. No consumer changes are needed. wabbit-site-core and tome-starter import only cinema-pack's `./meta`, `./demo`, `./render` and `./render/register` entries, none of which reached the barrel.

  • a2f2dfa: cinema-pack's block configs now load under plain Node — no bundler, no CSS loader. Eight of cinema-pack's block configs (`src/blocks/*/index.ts`) imported `videoSourceFields` from `@wabbit/tome-blocks-house/video`. That entry is a barrel that also re-exports the `'use client'` `EmbedFrame` and its CSS Module. Next.js resolves that CSS; plain Node does not. So any script that loaded cinema-pack's main entry outside a bundler (a Payload CLI command, a seed, a type generator) failed with `ERR_UNKNOWN_FILE_EXTENSION ".css"` before a single block registered. - **blocks-house (minor):** new `./video/fields` export for `videoSourceFields` and its resolvers. It points at the module the package already built and has no CSS on its import graph. `./video` is unchanged, so existing imports keep working. - **cinema-pack (patch):** the eight block configs import from `@wabbit/tome-blocks-house/video/fields`. Render components still use `./video`, because they need `EmbedFrame` and `useHlsVideo`. Verified against the rebuilt dist: importing `@wabbit/tome-blocks-cinema-pack` and calling `register()` under bare Node now registers all 13 blocks. The same import failed with the CSS error on `main`. cinema-pack's smoke test no longer needs a CSS stub loader, and the stub is removed. No consumer changes are needed. wabbit-site-core and tome-starter import only cinema-pack's `./meta`, `./demo`, `./render` and `./render/register` entries, none of which reached the barrel.
v0.2.0minor

7850b7a: Add a `./video` subpath export (source classification, Payload video-source fields, hls.js attach hook, embed iframe, and an injectable embed-poster resolver seam) as the substrate for `@wabbit/tome-blocks-cinema-pack`'s video-family blocks.

  • 7850b7a: Add a `./video` subpath export (source classification, Payload video-source fields, hls.js attach hook, embed iframe, and an injectable embed-poster resolver seam) as the substrate for `@wabbit/tome-blocks-cinema-pack`'s video-family blocks.

Blocks Console

v0.1.3
v0.1.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.1.1patch

bd092c2: Adds fourteen shared render cores for editorial blocks, such as accordion, tabs and data table, on the `./cores` subpath. The cores cover the accordion, anchor section, aside, callout, chapter divider, cross link, data table, epigraph, footnotes, image grid, key facts, series nav, spoiler and tabbed content. The signal theme and `@wabbit/tome-longform` render their matching blocks through them; the aside core serves both the aside and the author aside. They keep the existing contract (`classes` keyed by part, `variant`, `className`, `style`, optional parts off by default) and add `rootData`, data attributes written on the root, and a `link` slot where a block links somewhere. Rich text and pictures arrive already rendered, and no core resolves a block's width. Three cores are client components because they hold state: `AccordionCore` (open rows; closed bodies stay in the page, collapsed), `TabbedContentCore` (the selected tab; arrow keys, Home and End move between tabs and each panel is named by its tab) and `SpoilerCore` (revealed). The others are server components. The cores ship no stylesheet. New exports: the fourteen cores, their prop and data types, `CoreDataAttributes`, `CoreLinkComponent` and `dataAttributes`.

  • bd092c2: Adds fourteen shared render cores for editorial blocks, such as accordion, tabs and data table, on the `./cores` subpath. The cores cover the accordion, anchor section, aside, callout, chapter divider, cross link, data table, epigraph, footnotes, image grid, key facts, series nav, spoiler and tabbed content. The signal theme and `@wabbit/tome-longform` render their matching blocks through them; the aside core serves both the aside and the author aside. They keep the existing contract (`classes` keyed by part, `variant`, `className`, `style`, optional parts off by default) and add `rootData`, data attributes written on the root, and a `link` slot where a block links somewhere. Rich text and pictures arrive already rendered, and no core resolves a block's width. Three cores are client components because they hold state: `AccordionCore` (open rows; closed bodies stay in the page, collapsed), `TabbedContentCore` (the selected tab; arrow keys, Home and End move between tabs and each panel is named by its tab) and `SpoilerCore` (revealed). The others are server components. The cores ship no stylesheet. New exports: the fourteen cores, their prop and data types, `CoreDataAttributes`, `CoreLinkComponent` and `dataAttributes`.
  • 19a540c: Adds nine shared render cores for data panels (asset card, personnel card, metric grid, objective list, phase marker, progress bar, stat strip, map legend, sensor readout), exported from the root and `./cores` entries beside the existing cores, plus three helpers they use: `dataAttributes`, `headingTag` and `deltaDirection`. Each core is a server component that owns a block's markup and behavior and takes every class name from the skin that renders it, so two packs with different sizes, spacing and palettes render the same block through one core. Optional parts are off by default and switched on by props: among them the maker above the asset name, a status dot instead of a badge, a tile accent bar, objectives with no phase after the groups instead of under "Unassigned", hairlines and a framed number on the phase marker, segments sized by their share of the total with a legend after the track, a dot for a legend entry with no symbol, and a sensor header bar. Titles are headings whose level is an option (`headingLevel`, default 3), lists are lists, and the sensor table scrolls inside a labeled region a keyboard can reach. Every core also takes `data`, data attributes for its root. The cores ship no stylesheet.
  • 67cdd90: Adds eight shared render cores (comm intercept, comms transcript, classification banner, log header, redacted, system alert, ambient audio, threat panel) on a new `./cores` subpath. Each core is a server component that owns a block's markup and behavior and takes every class name from the skin that renders it (`classes`, keyed by part), so two packs with different sizes, spacing and palettes can render the same block through one core. Optional parts (a title row, a footer, stacked messages, hidden message text, a headed indicator box and others) are off by default and switched on by props. The cores ship no stylesheet. The package now declares `react` (`>=19.0.0`) as a peer, which every site that renders blocks already has.
v0.1.0minor

d99c698: New package: the console role names and a `consoleVar` helper that console-style block cores and the skins that color them will share. The package ships no blocks or render cores yet. It is a dependency substrate for block packs and has no peer dependencies.

  • d99c698: New package: the console role names and a `consoleVar` helper that console-style block cores and the skins that color them will share. The package ships no blocks or render cores yet. It is a dependency substrate for block packs and has no peer dependencies.

Blocks Content Writer

v0.49.4
v0.49.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.43.3patch

1636ffa: The archive `filed` demo posts carry a URL (the caller's `placeholderHref`, else `#`), so the gallery demo no longer links to `/posts/<slug>`, a route the host may not have. Real rendering is unchanged: a post without a URL still links to `<postsPath>/<slug>`.

  • 1636ffa: The archive `filed` demo posts carry a URL (the caller's `placeholderHref`, else `#`), so the gallery demo no longer links to `/posts/<slug>`, a route the host may not have. Real rendering is unchanged: a post without a URL still links to `<postsPath>/<slug>`.
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.43.0minor

0cb0e16: `archive` `filed` can now list filed documents with a download, a file size and a review date. A `FiledEntry` gains `download` (`{ href, label? }`), `fileType`, `fileSize` (bytes) and `due`. A row with a download prints a line after its summary: a plain `a[download]` (`data-filed-download`) whose visually hidden title makes every "Download" link a different name, then the type and size (`PDF · 1.2 MB`, `data-filed-size`) inside `p[data-filed-file]`. A review date adds `span[data-filed-due]` ("Next review due" and a `time`) to the row's meta line. The lead puts its download beside the read-more button inside `data-filed-action` (alone when the entry has no URL), and the size and review date join its meta line. An entry with no URL keeps a plain-text title, so the download is its only link. Size and type print only beside a download. The two new `labels`, `download` and `due`, default to "Download" and "Next review due"; an entry's own `download.label` wins for its link. New pure helpers on `./filed`: `formatFileSize` (1000-based: `340 KB`, `1.2 MB`) and `filedFileText`. An entry without the new fields renders exactly today's markup, and the `next` teaser is unchanged: the existing goldens, plus a new one cut from the pre-change renderer over revised dates, durations, topics, month-short dates, the aside slot, external entries, selected posts and custom labels, hold all five archive variants and both `filed` layouts byte for byte. No additive attributes on existing markup. `archive` `filed` gains a US date order, opt-in. `dateFormat: 'us'` prints every date in the block (the lead, its revised date, the rows, the review date and the `next` teaser) as a short month, day and year: `Oct 21, 2026` (`Oct 2026` for a month alone; a year alone stays the year). `formatFiledDate(value, 'us')` is the matching helper, and `FiledDateFormat` gains `us`. Omitted, the output is unchanged: the existing goldens hold byte for byte.

  • 0cb0e16: `archive` `filed` can now list filed documents with a download, a file size and a review date. A `FiledEntry` gains `download` (`{ href, label? }`), `fileType`, `fileSize` (bytes) and `due`. A row with a download prints a line after its summary: a plain `a[download]` (`data-filed-download`) whose visually hidden title makes every "Download" link a different name, then the type and size (`PDF · 1.2 MB`, `data-filed-size`) inside `p[data-filed-file]`. A review date adds `span[data-filed-due]` ("Next review due" and a `time`) to the row's meta line. The lead puts its download beside the read-more button inside `data-filed-action` (alone when the entry has no URL), and the size and review date join its meta line. An entry with no URL keeps a plain-text title, so the download is its only link. Size and type print only beside a download. The two new `labels`, `download` and `due`, default to "Download" and "Next review due"; an entry's own `download.label` wins for its link. New pure helpers on `./filed`: `formatFileSize` (1000-based: `340 KB`, `1.2 MB`) and `filedFileText`. An entry without the new fields renders exactly today's markup, and the `next` teaser is unchanged: the existing goldens, plus a new one cut from the pre-change renderer over revised dates, durations, topics, month-short dates, the aside slot, external entries, selected posts and custom labels, hold all five archive variants and both `filed` layouts byte for byte. No additive attributes on existing markup. `archive` `filed` gains a US date order, opt-in. `dateFormat: 'us'` prints every date in the block (the lead, its revised date, the rows, the review date and the `next` teaser) as a short month, day and year: `Oct 21, 2026` (`Oct 2026` for a month alone; a year alone stays the year). `formatFiledDate(value, 'us')` is the matching helper, and `FiledDateFormat` gains `us`. Omitted, the output is unchanged: the existing goldens hold byte for byte.
v0.40.1patch

9456a39: Archive blocks set to query a collection now share one read per page even when other Payload reads run in between, so pages with several archives no longer repeat the same query.

  • 9456a39: Archive blocks set to query a collection now share one read per page even when other Payload reads run in between, so pages with several archives no longer repeat the same query.
v0.40.0minor

9a82103: The `archive` block can now list documents from a collection: set `populateBy` to the new **Collection** (`query`) value. The stored value `populateBy: 'collection'` still renders nothing, so pages saved before this release look exactly as they did; an editor opts in per block by choosing Collection. The default stays `'collection'`. - `sort` (newest first, oldest first, title A to Z) joins the existing `relationTo`, `categories` and `limit`, which now show with the Collection value. - `archiveCollections` accepts objects as well as slugs: `{ slug, label, where, categoryField, dateField, map }`. `where` is a fixed constraint set in code and ANDed with the category filter; `map` shapes each row. - The query runs as the visitor with the collection's own read access, so drafts and private documents stay hidden. It runs once per request however many archive blocks share it, and a queried document that holds an archive block does not query again. - The `filed` variant reads the whole list (up to 200) so its serials still rank every entry; `showNewest` limits what prints. A site without its own loader can fill the reading room from a registered `posts` collection, and `entries` the site passes still win for the same URL. - `populate()`, `normalizeArchiveCollections()` and the `ArchiveCollectionOption` types are exported from the root entry. - **BREAKING:** `@wabbit/tome-core` (`>=1.22.0 <2.0.0`) is now a required peer dependency, used for `relationIdRaw`. Every Tome site already installs it; a site that does not must add it.

  • 9a82103: The `archive` block can now list documents from a collection: set `populateBy` to the new **Collection** (`query`) value. The stored value `populateBy: 'collection'` still renders nothing, so pages saved before this release look exactly as they did; an editor opts in per block by choosing Collection. The default stays `'collection'`. - `sort` (newest first, oldest first, title A to Z) joins the existing `relationTo`, `categories` and `limit`, which now show with the Collection value. - `archiveCollections` accepts objects as well as slugs: `{ slug, label, where, categoryField, dateField, map }`. `where` is a fixed constraint set in code and ANDed with the category filter; `map` shapes each row. - The query runs as the visitor with the collection's own read access, so drafts and private documents stay hidden. It runs once per request however many archive blocks share it, and a queried document that holds an archive block does not query again. - The `filed` variant reads the whole list (up to 200) so its serials still rank every entry; `showNewest` limits what prints. A site without its own loader can fill the reading room from a registered `posts` collection, and `entries` the site passes still win for the same URL. - `populate()`, `normalizeArchiveCollections()` and the `ArchiveCollectionOption` types are exported from the root entry. - **BREAKING:** `@wabbit/tome-core` (`>=1.22.0 <2.0.0`) is now a required peer dependency, used for `relationIdRaw`. Every Tome site already installs it; a site that does not must add it.
v0.38.0minor

7cb9c04: The `archive` block's `filed` variant takes an opt-in aside slot. The site passes a rendered node as `asideSlot` (for example a `course-card` `offer`) and, optionally, `asideLabel` (its accessible name). On the index layout the block sets the node beside the lead, in `<div data-filed-lead-row>` with the node in `<aside data-filed-aside>`, and the ruled rows run under both; from 768px the lead takes the wider column. The block builds nothing for the slot, the same contract as `signupSlot`. Absent (`undefined`, `null`, `false`), there is no wrapper and the markup is exactly what it was. The `next` layout ignores it. The `filed` variant takes an opt-in `dateFormat` field (`full`, the default, or `month-short`). `month-short` prints the ruled rows' dates as the short month alone ("Sept"); the lead and the next teaser keep the full date. `formatFiledDate(value, format?)` takes the same opt-in second argument; omitted, its output is unchanged. Absent, every render is byte-identical.

  • 7cb9c04: The `archive` block's `filed` variant takes an opt-in aside slot. The site passes a rendered node as `asideSlot` (for example a `course-card` `offer`) and, optionally, `asideLabel` (its accessible name). On the index layout the block sets the node beside the lead, in `<div data-filed-lead-row>` with the node in `<aside data-filed-aside>`, and the ruled rows run under both; from 768px the lead takes the wider column. The block builds nothing for the slot, the same contract as `signupSlot`. Absent (`undefined`, `null`, `false`), there is no wrapper and the markup is exactly what it was. The `next` layout ignores it. The `filed` variant takes an opt-in `dateFormat` field (`full`, the default, or `month-short`). `month-short` prints the ruled rows' dates as the short month alone ("Sept"); the lead and the next teaser keep the full date. `formatFiledDate(value, format?)` takes the same opt-in second argument; omitted, its output is unchanged. Absent, every render is byte-identical.
v0.37.2patch

e1c7107: Editorial Colophon shows its dates as dates, and the Archive `filed` variant keeps its text inside the page margins. - **Editorial Colophon:** the Published and Updated dates printed their raw values ("2026-04-15T00:00:00.000Z"). They now read "April 15, 2026", formatted in UTC by blocks-core's shared `formatDisplayDate` so server and client markup match. A value that won't parse is shown as authored, and the `<time datetime>` attribute keeps the original value. - **Archive `filed`:** its rows spanned the full row with only a 14px inline pad, so on a host that follows the page-grid contract the text sat 14px from the window edge. The index and the single "next" teaser now sit on the content lines, like the other archive variants.

  • e1c7107: Editorial Colophon shows its dates as dates, and the Archive `filed` variant keeps its text inside the page margins. - **Editorial Colophon:** the Published and Updated dates printed their raw values ("2026-04-15T00:00:00.000Z"). They now read "April 15, 2026", formatted in UTC by blocks-core's shared `formatDisplayDate` so server and client markup match. A value that won't parse is shown as authored, and the `<time datetime>` attribute keeps the original value. - **Archive `filed`:** its rows spanned the full row with only a 14px inline pad, so on a host that follows the page-grid contract the text sat 14px from the window edge. The index and the single "next" teaser now sit on the content lines, like the other archive variants.
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
  • f9fe8fe: Feature Hero shows icons instead of their names, and four blocks stay inside a phone-width window. - **Feature Hero:** a feature's `icon` is a lucide name ("shield-check") or an emoji, but the renderer printed the name as text in the card. It now uses extras' shared `resolveLucideIcon` (`./render/shared`, which extras has exported since 0.28.0), the same resolver extras and catalog-pack blocks use. An unknown name shows no icon, and emoji stay text. The extras map gains `shield-check` and `workflow`. - **Feature Hero grids:** the authored two- and three-column feature grids kept their columns on a phone and ran 380px past the window. Tracks are now `minmax(0, 1fr)`, and the grids stack below 640px. In a card with its image beside the text, the text drops below the image when it would get less than 10rem, instead of breaking words mid-way or pushing the card wider. - **Status Board:** the same fix to its card grid. It was 24px past its box at 375px and now stacks below 640px. - **Editorial Colophon:** its `3 / 11` placement counts the page grid's 16 desktop tracks, so below 1024px it overran into the right gutter. It sits on the content lines there and keeps its desktop placement. - **FAQ:** Full Width is padded by the page gutter, like Feature Hero's and CTA's Full Width.
v0.37.0minor

05c572a: `archive` gains a `filed` variant: a numbered reading-room index, and a single "next" teaser. - **`filed` index**: the newest entry as a lead (with its image and a read button), the rest as ruled rows with a number, a type, a date and a reading time, then a footer row with topics, a sign-up and an empty slot. It renders nothing when there are no entries. - **`FiledEntry`**: `title`, `url`, `date`, `revised`, `minutes`, `durationLabel`, `kind`, `summary`, `image`, `topics[]`, `serial`. Every link is the entry's own `url`, never a path rebuilt from a slug. A default Posts mapper reads `url` (else `<postsPath>/<slug>`, `/posts` by default), `publishedAt`, `readTime`, `meta.description`, `meta.image`, `categories` and an optional `essay.kind` and `essay.revisedAt`, and skips drafts and unpopulated ids. A site that maps posts itself passes `entries` to the renderer; those win for the same URL. - **New `@wabbit/tome-blocks-content-writer/filed` subpath**: the variant's pure data layer, for a site template that builds its own entries: `mapPostToFiledEntry`, `rankFiledEntries`, `collectFiledEntries`, `mapExternalToFiledEntry`, `collectFiledTopics`, `formatFiledDate`, `formatFiledSerial`, `filedTimeText` and the `FiledEntry` type. React-free and Payload-free. - **Typed entries and numbers**: `externalEntries[]` (`kind`, `title`, `line`, `date`, `durationLabel`, `href`) hold talks, podcasts and a book. A `serial` the site supplies is honored; otherwise each entry is numbered by its date rank across the whole list (oldest is 1). `showNewest` limits how many print without changing the numbers. - **`layout: 'next'`**: exactly one teaser, the first entry supplied. A lone teaser prints a number only when one is supplied. - **Footer slots**: the sign-up is a slot. The block prints an optional heading and line and renders the form your site passes as `signupSlot` (with `signupSlotId` written to `data-form-id`); it imports no forms engine. The empty slot takes a label, a line and a link. `excludeEntry(entry)` lets a site drop entries it must not list. - **Opener**: optional `kicker` (`data-lane-kicker`), `heading` (`data-filed-heading`) and `deck` (`data-block-deck`) open the index, each printed only when it has text, so a theme can put a section number and label in the left lane and the heading on the prose line. The sign-up's heading and line carry `data-filed-signup-heading` and `data-filed-signup-body`. - **Fields**: `kicker`, `heading`, `deck`, `layout`, `lead`, `showNewest`, `postsPath`, `labels` (`readMore`, `next`, `serial`, `revised`, `postKind`), `externalEntries` and `footer` (`showTopics`, `topicsLabel`, `signup`, `slot`), each shown only while the variant is `filed`, with no stored defaults. - **Hooks**: `data-block-variant="filed"`, `data-archive-layout`, `data-filed-lead`, `-list`, `-row`, `-side`, `-serial`, `-kind`, `-title`, `-line`, `-meta`, `-date`, `-time`, `-image`, `-action`, `-footer`, `-topics`, `-topic`, `-signup`, `-signup-slot`, `-slot`, `-next`, `-label`. Lead, rows and footer carry `data-tome-reveal-item`. The default styling is theme-neutral. The `default`, `editorial`, `dark` and `compact-list` variants, and every archive document saved before, render byte for byte as before (pinned by a snapshot test).

  • 05c572a: `archive` gains a `filed` variant: a numbered reading-room index, and a single "next" teaser. - **`filed` index**: the newest entry as a lead (with its image and a read button), the rest as ruled rows with a number, a type, a date and a reading time, then a footer row with topics, a sign-up and an empty slot. It renders nothing when there are no entries. - **`FiledEntry`**: `title`, `url`, `date`, `revised`, `minutes`, `durationLabel`, `kind`, `summary`, `image`, `topics[]`, `serial`. Every link is the entry's own `url`, never a path rebuilt from a slug. A default Posts mapper reads `url` (else `<postsPath>/<slug>`, `/posts` by default), `publishedAt`, `readTime`, `meta.description`, `meta.image`, `categories` and an optional `essay.kind` and `essay.revisedAt`, and skips drafts and unpopulated ids. A site that maps posts itself passes `entries` to the renderer; those win for the same URL. - **New `@wabbit/tome-blocks-content-writer/filed` subpath**: the variant's pure data layer, for a site template that builds its own entries: `mapPostToFiledEntry`, `rankFiledEntries`, `collectFiledEntries`, `mapExternalToFiledEntry`, `collectFiledTopics`, `formatFiledDate`, `formatFiledSerial`, `filedTimeText` and the `FiledEntry` type. React-free and Payload-free. - **Typed entries and numbers**: `externalEntries[]` (`kind`, `title`, `line`, `date`, `durationLabel`, `href`) hold talks, podcasts and a book. A `serial` the site supplies is honored; otherwise each entry is numbered by its date rank across the whole list (oldest is 1). `showNewest` limits how many print without changing the numbers. - **`layout: 'next'`**: exactly one teaser, the first entry supplied. A lone teaser prints a number only when one is supplied. - **Footer slots**: the sign-up is a slot. The block prints an optional heading and line and renders the form your site passes as `signupSlot` (with `signupSlotId` written to `data-form-id`); it imports no forms engine. The empty slot takes a label, a line and a link. `excludeEntry(entry)` lets a site drop entries it must not list. - **Opener**: optional `kicker` (`data-lane-kicker`), `heading` (`data-filed-heading`) and `deck` (`data-block-deck`) open the index, each printed only when it has text, so a theme can put a section number and label in the left lane and the heading on the prose line. The sign-up's heading and line carry `data-filed-signup-heading` and `data-filed-signup-body`. - **Fields**: `kicker`, `heading`, `deck`, `layout`, `lead`, `showNewest`, `postsPath`, `labels` (`readMore`, `next`, `serial`, `revised`, `postKind`), `externalEntries` and `footer` (`showTopics`, `topicsLabel`, `signup`, `slot`), each shown only while the variant is `filed`, with no stored defaults. - **Hooks**: `data-block-variant="filed"`, `data-archive-layout`, `data-filed-lead`, `-list`, `-row`, `-side`, `-serial`, `-kind`, `-title`, `-line`, `-meta`, `-date`, `-time`, `-image`, `-action`, `-footer`, `-topics`, `-topic`, `-signup`, `-signup-slot`, `-slot`, `-next`, `-label`. Lead, rows and footer carry `data-tome-reveal-item`. The default styling is theme-neutral. The `default`, `editorial`, `dark` and `compact-list` variants, and every archive document saved before, render byte for byte as before (pinned by a snapshot test).
v0.36.3patch

1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.

  • 1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.
v0.36.2patch

3a84675: PageHero's primary button label and PostHero's text over a photo are readable again in every theme. PageHero: a host's global link reset (`:root a { color: inherit }`) outranked the single `.link` class, so the pill took the hero's text color. Over an image that is the pill's own background, and the label vanished. The color rules now use doubled classes, the convention HeaderCTAButtons, Ledger and CampaignTiers already follow. PostHero (`image-overlay`): the scrim is built from foreground, but the text stayed on-primary, so in a dark theme it was light text on a light wash. Over an image the text now takes background, the scrim's partner, the same pairing PageHero, BlogHero and ChapterHero use.

  • 3a84675: PageHero's primary button label and PostHero's text over a photo are readable again in every theme. PageHero: a host's global link reset (`:root a { color: inherit }`) outranked the single `.link` class, so the pill took the hero's text color. Over an image that is the pill's own background, and the label vanished. The color rules now use doubled classes, the convention HeaderCTAButtons, Ledger and CampaignTiers already follow. PostHero (`image-overlay`): the scrim is built from foreground, but the text stayed on-primary, so in a dark theme it was light text on a light wash. Over an image the text now takes background, the scrim's partner, the same pairing PageHero, BlogHero and ChapterHero use.
v0.34.1patch

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

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

3e4f0a7: Narrow-screen rules that never fired now fire. These blocks wrote their phone layout as an unnamed `@container` query, but nothing around them declares a size container (not tome-ui, the blocks core or the starter), so the rules never matched: the `high-impact-hero` `split` and default layouts never stacked on phones. Declaring the container on the block root would not work either, because size containment turns a `subgrid` root into a plain grid and drops the page-grid lines its children sit on. The rules are viewport queries now, at the same breakpoints; the blocks are full-bleed, so the viewport is their width. - `@wabbit/tome-blocks-marketing-starter`: `high-impact-hero` (two columns stack to one, large headline sizes step down, at 768px and below). - `@wabbit/tome-blocks-extras`: `BlogHero`, `ChapterHero`, `CustomHero`, `ImageHero`, `LowImpactHero`, `MediumImpactHero`, `PageHero`, `StudyHero`, `TypographyHero` (768px) and `SectionHeader` (640px). - `@wabbit/tome-blocks-content-writer`: `PostHero` (768px). - `@wabbit/tome-blocks-agency-essentials`: `SplitView` stacks at 768px. Above those widths nothing changes.

  • 3e4f0a7: Narrow-screen rules that never fired now fire. These blocks wrote their phone layout as an unnamed `@container` query, but nothing around them declares a size container (not tome-ui, the blocks core or the starter), so the rules never matched: the `high-impact-hero` `split` and default layouts never stacked on phones. Declaring the container on the block root would not work either, because size containment turns a `subgrid` root into a plain grid and drops the page-grid lines its children sit on. The rules are viewport queries now, at the same breakpoints; the blocks are full-bleed, so the viewport is their width. - `@wabbit/tome-blocks-marketing-starter`: `high-impact-hero` (two columns stack to one, large headline sizes step down, at 768px and below). - `@wabbit/tome-blocks-extras`: `BlogHero`, `ChapterHero`, `CustomHero`, `ImageHero`, `LowImpactHero`, `MediumImpactHero`, `PageHero`, `StudyHero`, `TypographyHero` (768px) and `SectionHeader` (640px). - `@wabbit/tome-blocks-content-writer`: `PostHero` (768px). - `@wabbit/tome-blocks-agency-essentials`: `SplitView` stacks at 768px. Above those widths nothing changes.
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.28.0patch

483e0a1: Preview data in the content writer pack now uses invented brand and people names. The archive, editorial figure and post hero previews use fictional names. Block fields and variants are unchanged.

  • 483e0a1: Preview data in the content writer pack now uses invented brand and people names. The archive, editorial figure and post hero previews use fictional names. Block fields and variants are unchanged.
  • 6301bf1: The post hero is now listed as a Tome block in the gallery instead of carrying a legacy source badge.
  • 58655f4: Blocks now carry the neutral Tome source tag instead of the legacy source tag, so the gallery lists them as Tome blocks; stored content is unchanged.
v0.27.0patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.24.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 10 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 10 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.18.3patch

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

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

0aa80a3: Drops the unused `@wabbit/tome-blocks-extras` peer dependency; nothing in the package imported it.

  • 0aa80a3: Drops the unused `@wabbit/tome-blocks-extras` peer dependency; nothing in the package imported it.
v0.18.0patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0 - @wabbit/tome-blocks-extras@0.16.0
v0.15.24patch

1471078: Post Hero and Custom Hero catalog copy now describes what the blocks render, not their upstream military lore. Post Hero renders a full-bleed cover-image header with an accent-marked category tag, a visibility badge, and a mono byline strip — nothing about it reads "SITREP tactical," so the description, editorial role, and both variant descriptions now say what the reader sees. Custom Hero's description drops the "COP tactical layouts / SITREP post headers" jargon for plain treatment names. blocks-core BLOCK_CATALOG mirror entries updated to match. Enum IDs (`sitrep1`, `cop1`…) and schema field names are unchanged, per the 71d3b09 purge discipline.

  • 1471078: Post Hero and Custom Hero catalog copy now describes what the blocks render, not their upstream military lore. Post Hero renders a full-bleed cover-image header with an accent-marked category tag, a visibility badge, and a mono byline strip — nothing about it reads "SITREP tactical," so the description, editorial role, and both variant descriptions now say what the reader sees. Custom Hero's description drops the "COP tactical layouts / SITREP post headers" jargon for plain treatment names. blocks-core BLOCK_CATALOG mirror entries updated to match. Enum IDs (`sitrep1`, `cop1`…) and schema field names are unchanged, per the 71d3b09 purge discipline.
  • Updated dependencies [1471078] - @wabbit/tome-blocks-extras@0.15.24 - @wabbit/tome-blocks-core@0.15.24
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
  • Updated dependencies [54ff357] - @wabbit/tome-blocks-extras@0.15.12
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
  • Updated dependencies [1bebcdc]
  • Updated dependencies [48773ac] - @wabbit/tome-blocks-extras@0.15.11
v0.15.9patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-extras@0.15.9 - @wabbit/tome-blocks-core@0.15.9
v0.15.5patch

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.
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-extras@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0
v0.13.0patch

Updated dependencies [f4d55c9]

  • Updated dependencies [f4d55c9]
  • Updated dependencies [eb403d4] - @wabbit/tome-blocks-core@0.13.0 - @wabbit/tome-blocks-extras@0.13.0
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2
v0.11.0patch

36e537a: Documentation truth pass: all "hydrates from @wabbit/tome-X when present" claims across READMEs, block meta, bundle descriptions, render headers, and admin field descriptions are rewritten to the honest contract — these blocks are fully static today; the layer-presence flags are the seam for a future hydration wave (trigger documented in place). content-writer's `RelatedPosts` (auto mode) and `Archive` (collection mode) no longer render fake placeholder UI — the unimplemented modes render nothing and say so in the admin field description.

  • 36e537a: Documentation truth pass: all "hydrates from @wabbit/tome-X when present" claims across READMEs, block meta, bundle descriptions, render headers, and admin field descriptions are rewritten to the honest contract — these blocks are fully static today; the layer-presence flags are the seam for a future hydration wave (trigger documented in place). content-writer's `RelatedPosts` (auto mode) and `Archive` (collection mode) no longer render fake placeholder UI — the unimplemented modes render nothing and say so in the admin field description.
  • 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-blocks-extras@0.11.0
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0
v0.9.5patch

Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.

  • Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.
  • Updated dependencies - @wabbit/tome-blocks-extras@0.9.5
v0.9.4patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-extras@0.9.4
v0.9.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2
v0.9.1patch

Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1

  • Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1
v0.9.0minor

c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.

  • c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0
v0.8.0patch

Updated dependencies [249b670]

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

28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.

  • 28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.
  • 8958d41: Expose a client-safe `./meta` export on each block pack: payload-free block descriptor metadata (slug/name/description/category/tags/source + variants), separate from the payload-importing root barrel. This is the companion to the `./demo` export. Each block's `meta` literal is now extracted into a co-located payload-free `meta` module that the block config imports, and a pack-level `./meta` entry exposes the full descriptor list as `<pack>BlockMeta`. A consumer registering packs client-side (the wabbit `/blocks` gallery storefront, B6) can now read block metadata for gallery entries without importing the root barrel, which eagerly pulls each block's config (`payload` -> `richtext-lexical` -> `pino` -> `worker_threads`) into the browser bundle. Additive and behavior-preserving: `defineBlock` receives the same meta object (now imported rather than inline); the block registry, configs, demos, and existing exports are unchanged. The pack `BlockMeta` array is also re-exported from the root barrel for path-alias consumers.
  • Updated dependencies [28802fa]
  • Updated dependencies [66c611c]
  • Updated dependencies [8958d41] - @wabbit/tome-blocks-extras@0.7.0 - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2
v0.5.10patch

497409b: Fix mobile horizontal-overflow in four blocks. `dispatchRedacted` (inline variant), `editorialColophon` (meta row), `longformTabbedContent` (tab/pill strip), and `longformChapterDivider` (numbered row) forced page-level horizontal scroll at narrow viewports (≤375px). Root-cause CSS — the redacted bar caps at `max-width:100%`, the colophon meta wraps, the tab strips scroll horizontally within their own container, and the chapter-divider content can shrink/wrap. No change to ≥768px layout. - @wabbit/tome-blocks-core@0.5.9

  • 497409b: Fix mobile horizontal-overflow in four blocks. `dispatchRedacted` (inline variant), `editorialColophon` (meta row), `longformTabbedContent` (tab/pill strip), and `longformChapterDivider` (numbered row) forced page-level horizontal scroll at narrow viewports (≤375px). Root-cause CSS — the redacted bar caps at `max-width:100%`, the colophon meta wraps, the tab strips scroll horizontally within their own container, and the chapter-divider content can shrink/wrap. No change to ≥768px layout. - @wabbit/tome-blocks-core@0.5.9
v0.5.9patch

@wabbit/tome-blocks-core@0.5.9

  • @wabbit/tome-blocks-core@0.5.9
  • @wabbit/tome-blocks-extras@0.5.9
v0.5.7patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
  • @wabbit/tome-blocks-extras@0.5.7
v0.5.0minor

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

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

97d2311: `EditorialSidenote` placement migrated from numeric `grid-column: 10 / 13` to the named `marginalia-right-start / marginalia-right-end` line names exposed by `@wabbit/tome-ui/grid` 0.8.0+. Closes the last bespoke numeric-coords block in `tome-blocks-content-writer` (the longform pack already uses named lines after the 2026-05-10 marginalia tracks rollout). The visual outcome should be identical on grids where the named lines resolve to the same numeric range. On grids where consumers override `--tome-grid-marginalia-right-cols` (or related custom properties), `EditorialSidenote` now follows the marginalia track width correctly instead of remaining pinned to absolute columns 10-13. `max-width: 30ch` content cap and mobile fallback (`content-start / content-end` below 768px) are unchanged. Linked-cohort impact: the umbrella `@wabbit/tome-blocks` aggregator also bumps to 0.4.4 (transitive via updateInternalDependencies). Sibling packs (`tome-blocks-{core, marketing-starter, agency-essentials, editorial-pack, signal-theme, lms-pack, catalog-pack, sc-pack, extras}`) stay at their existing versions — `linked` in changesets only enforces version sync when a package actually bumps, not forced co-bumping. - @wabbit/tome-blocks-core@0.4.3

  • 97d2311: `EditorialSidenote` placement migrated from numeric `grid-column: 10 / 13` to the named `marginalia-right-start / marginalia-right-end` line names exposed by `@wabbit/tome-ui/grid` 0.8.0+. Closes the last bespoke numeric-coords block in `tome-blocks-content-writer` (the longform pack already uses named lines after the 2026-05-10 marginalia tracks rollout). The visual outcome should be identical on grids where the named lines resolve to the same numeric range. On grids where consumers override `--tome-grid-marginalia-right-cols` (or related custom properties), `EditorialSidenote` now follows the marginalia track width correctly instead of remaining pinned to absolute columns 10-13. `max-width: 30ch` content cap and mobile fallback (`content-start / content-end` below 768px) are unchanged. Linked-cohort impact: the umbrella `@wabbit/tome-blocks` aggregator also bumps to 0.4.4 (transitive via updateInternalDependencies). Sibling packs (`tome-blocks-{core, marketing-starter, agency-essentials, editorial-pack, signal-theme, lms-pack, catalog-pack, sc-pack, extras}`) stay at their existing versions — `linked` in changesets only enforces version sync when a package actually bumps, not forced co-bumping. - @wabbit/tome-blocks-core@0.4.3
v0.4.2patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2
v0.4.1patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0
v0.4.0patch

Updated dependencies [b76f684]

  • Updated dependencies [b76f684]
  • Updated dependencies [90a694d] - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-extras@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Marketing Starter

v0.50.1
v0.50.1patch

3650b55: Hero copy stays light over a background image in dark themes, and CTA headings, FAQ answers and hero/CTA buttons now size and measure correctly. - High Impact Hero: with a background image, the text, plate and scrim use the theme-fixed dark pair instead of flipping with the theme; `--hi-bg-behind` and `--tome-hero-scrim` still override. - Demo props now pass the block's own schema (`backgroundType` is `color` or `image`; background colors are real CSS values). - CTA: a heading in the rich text renders at heading size. - FAQ: answers are capped at a 65ch reading measure. - Hero and CTA link buttons meet a 44px tap target. - Terminal Steps and Proof Ledger headings use `--tome-type-size-hero` (and the ledger's second heading `--tome-text-h2`), so a band heading never outgrows the hero heading.

  • 3650b55: Hero copy stays light over a background image in dark themes, and CTA headings, FAQ answers and hero/CTA buttons now size and measure correctly. - High Impact Hero: with a background image, the text, plate and scrim use the theme-fixed dark pair instead of flipping with the theme; `--hi-bg-behind` and `--tome-hero-scrim` still override. - Demo props now pass the block's own schema (`backgroundType` is `color` or `image`; background colors are real CSS values). - CTA: a heading in the rich text renders at heading size. - FAQ: answers are capped at a 65ch reading measure. - Hero and CTA link buttons meet a 44px tap target. - Terminal Steps and Proof Ledger headings use `--tome-type-size-hero` (and the ledger's second heading `--tome-text-h2`), so a band heading never outgrows the hero heading.
v0.50.0minor

2288bb9: The `split` hero takes an optional choice group, and a new `slat-deck` block shows two to seven photo slats that widen on hover, focus or tap. The hero's `choice` group is a question and two to six choices, each with a label, one answering sentence and one action link. It renders as a `fieldset` of radios and switches with CSS only, so it needs no script; a hero with no choices renders as before. The `slat-deck` block (Payload slug `slatDeck`, config subpath `./blocks/slat-deck`) opens one slat at a time, stacks below 768px and removes its transitions under reduced motion. A name over 24 characters needs a short name so a collapsed slat never clips it. Both ship neutral `data-*` theme hooks, listed in the package README.

  • 2288bb9: The `split` hero takes an optional choice group, and a new `slat-deck` block shows two to seven photo slats that widen on hover, focus or tap. The hero's `choice` group is a question and two to six choices, each with a label, one answering sentence and one action link. It renders as a `fieldset` of radios and switches with CSS only, so it needs no script; a hero with no choices renders as before. The `slat-deck` block (Payload slug `slatDeck`, config subpath `./blocks/slat-deck`) opens one slat at a time, stacks below 768px and removes its transitions under reduced motion. A name over 24 characters needs a short name so a collapsed slat never clips it. Both ship neutral `data-*` theme hooks, listed in the package README.
v0.49.7patch

95b51e3: Layer Stack, Term Rows and Proof Ledger take their ground from the theme's muted surface. They mixed the page ink into the page background, which turned a warm palette gray. They now read `--tome-color-surface-muted` and keep the mix as the fallback for a theme that doesn't set it.

  • 95b51e3: Layer Stack, Term Rows and Proof Ledger take their ground from the theme's muted surface. They mixed the page ink into the page background, which turned a warm palette gray. They now read `--tome-color-surface-muted` and keep the mix as the fallback for a theme that doesn't set it.
v0.49.6patch

76fc86a: Code in Terminal Steps, Layer Stack, Switch Tally, Proof Ledger and Command Close stays readable on sites that fill `pre` and `code`. These blocks set a text color but no background, so a host's fill could leave the text unreadable. They now clear it.

  • 76fc86a: Code in Terminal Steps, Layer Stack, Switch Tally, Proof Ledger and Command Close stays readable on sites that fill `pre` and `code`. These blocks set a text color but no background, so a host's fill could leave the text unreadable. They now clear it.
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.49.3patch

27911be: Terminal Steps and Proof Ledger now land on the right gallery shelves. Terminal Steps carried a `code` tag that put it among the primitives, beside Spacer and Text Block, and it now sits with content. Proof Ledger matched no shelf and fell into a category of its own, and it now sits with data. Nothing changes on a page: tags only affect how a gallery groups the blocks.

  • 27911be: Terminal Steps and Proof Ledger now land on the right gallery shelves. Terminal Steps carried a `code` tag that put it among the primitives, beside Spacer and Text Block, and it now sits with content. Proof Ledger matched no shelf and fell into a category of its own, and it now sits with data. Nothing changes on a page: tags only affect how a gallery groups the blocks.
v0.49.2patch

27139be: Full-width bands on a site with a default column span, and two details the Starter's home page needed. Stop Viewer, Switch Tally, Proof Ledger, Terminal Steps, Layer Stack and Term Rows gain the optional `columnSpan` field. These blocks are full-width bands, but on a site with a default span such as `2 / -2` they stopped at the content lines and their text sat against the band's edge, because the field that moves the block's wrapper did not exist. Set `columnSpan` to `1 / -1`. A stored block with no span renders the same markup. The Command Close underlines the links in its door line, which a site link reset could strip. Proof Ledger sets a run wrapped in backticks in the mono face, in a promise's body and the figure caption, where the backticks used to print. The section header gains an optional `backgroundColor`, which `RenderBlocks` paints on the block's wrapper. `accent-tint` needs a mapping in the site's block backgrounds. On a painted band the Ruled Split drops its top rule.

  • 27139be: Full-width bands on a site with a default column span, and two details the Starter's home page needed. Stop Viewer, Switch Tally, Proof Ledger, Terminal Steps, Layer Stack and Term Rows gain the optional `columnSpan` field. These blocks are full-width bands, but on a site with a default span such as `2 / -2` they stopped at the content lines and their text sat against the band's edge, because the field that moves the block's wrapper did not exist. Set `columnSpan` to `1 / -1`. A stored block with no span renders the same markup. The Command Close underlines the links in its door line, which a site link reset could strip. Proof Ledger sets a run wrapped in backticks in the mono face, in a promise's body and the figure caption, where the backticks used to print. The section header gains an optional `backgroundColor`, which `RenderBlocks` paints on the block's wrapper. `accent-tint` needs a mapping in the site's block backgrounds. On a painted band the Ruled Split drops its top rule.
v0.49.0minor

d08e2ee: Six new free blocks for a product home page and an about page (stop viewer, switch tally, proof ledger, terminal steps, layer stack, term rows) and a Command Close variant of the call to action. Additive: stored documents and the existing blocks and variants render the same markup. Each new block ships a demo and a gallery entry, and none needs a paid pack. - `stop-viewer`: a rail of stops beside one framed picture, with a site/admin toggle (hidden for a stop with no admin picture), a route bar, a description, a command or a note, and an open button. The picture links to the stop's page. Every stop is in the server HTML and the first is selected, so it reads and indexes without JavaScript; the rail and toggle are plain buttons with `aria-pressed`. - `switch-tally`: switches for add-ons with a running total and the exact install line, each package printed once. The currency, the period, the command prefix, every price and every label are fields; nothing is priced in code. The whole switch row is one 44px button with `role="switch"`, and on narrow screens the total stays pinned above the switches. - `proof-ledger`: a heading, promises, a large figure with its caption held together and centered against a window that names each check, and a row of small figures. Every number is stored content. - `terminal-steps`: numbered steps beside a terminal pane whose lines are commands or output. Step bodies take rich text or plain text. - `layer-stack`: rows from the top of a system to its base, each with a label, a body and a tone (accent, neutral, optional with a dashed outline, base inverted). - `term-rows`: rows of a large term beside its reason, as a definition list. - `cta` `command-close`: an inverted closing band with the title at the left and, at the right, a lead, an optional command with a Copy button, a reassurance line, link buttons and a quieter line for a second audience. With no command set, the command area and the Copy button do not render. The new fields (`closeTitle`, `closeLead`, `closeCommand`, `closeReassurance`, `closeDoor`, `copyLabel`, `copiedLabel`) show only for this variant. All of it is painted from the page colors (the foreground and background swapped for dark bands, the primary color for the accent band), so it follows the site's palette. Motion respects reduced motion, and the bands show in full when scripting is off. The package now has three small client modules (`StopViewer.client`, `SwitchTally.client`, `CopyCommand.client`); the other new renderers are server components.

  • d08e2ee: Six new free blocks for a product home page and an about page (stop viewer, switch tally, proof ledger, terminal steps, layer stack, term rows) and a Command Close variant of the call to action. Additive: stored documents and the existing blocks and variants render the same markup. Each new block ships a demo and a gallery entry, and none needs a paid pack. - `stop-viewer`: a rail of stops beside one framed picture, with a site/admin toggle (hidden for a stop with no admin picture), a route bar, a description, a command or a note, and an open button. The picture links to the stop's page. Every stop is in the server HTML and the first is selected, so it reads and indexes without JavaScript; the rail and toggle are plain buttons with `aria-pressed`. - `switch-tally`: switches for add-ons with a running total and the exact install line, each package printed once. The currency, the period, the command prefix, every price and every label are fields; nothing is priced in code. The whole switch row is one 44px button with `role="switch"`, and on narrow screens the total stays pinned above the switches. - `proof-ledger`: a heading, promises, a large figure with its caption held together and centered against a window that names each check, and a row of small figures. Every number is stored content. - `terminal-steps`: numbered steps beside a terminal pane whose lines are commands or output. Step bodies take rich text or plain text. - `layer-stack`: rows from the top of a system to its base, each with a label, a body and a tone (accent, neutral, optional with a dashed outline, base inverted). - `term-rows`: rows of a large term beside its reason, as a definition list. - `cta` `command-close`: an inverted closing band with the title at the left and, at the right, a lead, an optional command with a Copy button, a reassurance line, link buttons and a quieter line for a second audience. With no command set, the command area and the Copy button do not render. The new fields (`closeTitle`, `closeLead`, `closeCommand`, `closeReassurance`, `closeDoor`, `copyLabel`, `copiedLabel`) show only for this variant. All of it is painted from the page colors (the foreground and background swapped for dark bands, the primary color for the accent band), so it follows the site's palette. Motion respects reduced motion, and the bands show in full when scripting is off. The package now has three small client modules (`StopViewer.client`, `SwitchTally.client`, `CopyCommand.client`); the other new renderers are server components.
v0.47.0minor

b6b66ea: `testimonial`: consent-aware hiding, and an opt-in disclaimer line. A row from a testimonials collection (`tome-local`'s `local-testimonials`, or any collection with a `consent` group) whose `consent.given` is `false` is no longer shown, in single, carousel and quotes-grid; the carousel's dots count only the rows that show, and a block with nothing left to show renders nothing. A row with no consent record, or one with no `given` yet, renders exactly as before, and so do inline entries (the existing golden holds the default markup byte for byte). The stored shape is unchanged. `testimonialBlock.block({ disclaimerLine: true })` adds a localized `disclaimer` text field, printed once under the quotes as `data-testimonial-disclaimer`. Off (the default), the block's fields are identical; turning it on adds one column, so SQL sites need a migration at that point.

  • b6b66ea: `testimonial`: consent-aware hiding, and an opt-in disclaimer line. A row from a testimonials collection (`tome-local`'s `local-testimonials`, or any collection with a `consent` group) whose `consent.given` is `false` is no longer shown, in single, carousel and quotes-grid; the carousel's dots count only the rows that show, and a block with nothing left to show renders nothing. A row with no consent record, or one with no `given` yet, renders exactly as before, and so do inline entries (the existing golden holds the default markup byte for byte). The stored shape is unchanged. `testimonialBlock.block({ disclaimerLine: true })` adds a localized `disclaimer` text field, printed once under the quotes as `data-testimonial-disclaimer`. Off (the default), the block's fields are identical; turning it on adds one column, so SQL sites need a migration at that point.
v0.45.0patch

a6eaeb2: `BlockStyles` takes `dedupe={false}` so every instance emits its sheet, for a block rendered ahead and chosen on the client. Banner uses it: a layout that renders one alert per tenant and lets the header show the current one no longer gets an unstyled alert when another tenant's alert rendered first. marketing-starter now needs blocks-core 0.45.0 or later.

  • a6eaeb2: `BlockStyles` takes `dedupe={false}` so every instance emits its sheet, for a block rendered ahead and chosen on the client. Banner uses it: a layout that renders one alert per tenant and lets the header show the current one no longer gets an unstyled alert when another tenant's alert rendered first. marketing-starter now needs blocks-core 0.45.0 or later.
v0.44.0minor

7f26e12: The banner can now be dismissed by the visitor, and the venue hero takes up to four actions and a photo credit. Everything is opt-in: a banner or hero you already have renders exactly as before. - **Dismissible banner.** Turn on "Visitors can dismiss it" and give the message a short key (for example `closure-2026-11-02`). A close button appears, and the visitor's choice is remembered in their own browser only, either for the visit, for 24 hours, or until you change the key. Nothing is sent to a server and no cookie is set. If the browser blocks storage the banner simply shows every time. Change the key and everyone sees the new message. New fields on the banner: `dismissible`, `dismissKey`, `dismissMemory`, `dismissLabel` and `showUntil` (after that time the banner no longer renders, even without the dismiss option). A dismissed banner is hidden before first paint by one small inline script; if your site sets a strict content-security policy, allow that script by hash or nonce (the script is a fixed string exported as `BANNER_GUARD_SCRIPT` from the banner's `dismiss` module), otherwise the banner hides a moment after load instead. With the option off there is no button, no script and no client code in the markup. - **Venue hero: up to four actions.** The `venue` variant of `high-impact-hero` now accepts four links instead of two. Every other variant stays at two, enforced on save; stored documents and their admin screens are unchanged. - **Venue hero: photo credit.** A new optional `venue.mediaCredit` text prints a credit on the photo as `figcaption[data-hero-credit]`, after the image. It needs a photo, and only the `venue` variant shows it. When it is set, the photo wrapper is a `figure` instead of a `div`; without a credit the markup is unchanged. Themes can restyle the chip with `--tome-hero-credit-ink` and `--tome-hero-credit-bg`. New fields are nullable columns: on a SQL database, regenerate your migration. The banner now ships one small client file (`BannerDismiss.client.tsx`), loaded only when the banner is dismissible.

  • 7f26e12: The banner can now be dismissed by the visitor, and the venue hero takes up to four actions and a photo credit. Everything is opt-in: a banner or hero you already have renders exactly as before. - **Dismissible banner.** Turn on "Visitors can dismiss it" and give the message a short key (for example `closure-2026-11-02`). A close button appears, and the visitor's choice is remembered in their own browser only, either for the visit, for 24 hours, or until you change the key. Nothing is sent to a server and no cookie is set. If the browser blocks storage the banner simply shows every time. Change the key and everyone sees the new message. New fields on the banner: `dismissible`, `dismissKey`, `dismissMemory`, `dismissLabel` and `showUntil` (after that time the banner no longer renders, even without the dismiss option). A dismissed banner is hidden before first paint by one small inline script; if your site sets a strict content-security policy, allow that script by hash or nonce (the script is a fixed string exported as `BANNER_GUARD_SCRIPT` from the banner's `dismiss` module), otherwise the banner hides a moment after load instead. With the option off there is no button, no script and no client code in the markup. - **Venue hero: up to four actions.** The `venue` variant of `high-impact-hero` now accepts four links instead of two. Every other variant stays at two, enforced on save; stored documents and their admin screens are unchanged. - **Venue hero: photo credit.** A new optional `venue.mediaCredit` text prints a credit on the photo as `figcaption[data-hero-credit]`, after the image. It needs a photo, and only the `venue` variant shows it. When it is set, the photo wrapper is a `figure` instead of a `div`; without a credit the markup is unchanged. Themes can restyle the chip with `--tome-hero-credit-ink` and `--tome-hero-credit-bg`. New fields are nullable columns: on a SQL database, regenerate your migration. The banner now ships one small client file (`BannerDismiss.client.tsx`), loaded only when the banner is dismissible.
  • 8d7c046: The testimonial `quotes-grid` layout marks its first quote for themes. The first quote's `<figure data-testimonial-cell>` carries `data-testimonial-lead`, so a theme can set a lead quote. The stat tile never takes it, whatever its position. Other layouts and the other quotes render as before. The `venue` hero photo is the page's LCP image, so it now loads first. The `venue` variant's background photo is asked for with `priority` and `fetchPriority="high"` (eager, high fetch priority). It was `loading="lazy"` with no priority. A media adapter that renders `next/image` must forward `fetchPriority` itself (next/image 16 does not infer it from `priority`). Only `venue` changes; the other variants render as before, and the venue golden is unchanged because its stand-in adapter ignores the hint. The `venue` photo wrapper no longer depends on the engine to find its width. `[data-hero-media]` on the `venue` variant has a stretched height and an image with its own ratio, so WebKit (and every engine at 1024px) could work its width out from the row height and leave the photo narrow. The pack's stylesheet now states `inline-size: 100%` on the wrapper, so every theme gets a full-width photo without patching it. CSS only: where the width already resolved to 100% the layout is unchanged, and the markup (the venue golden) is identical.
v0.43.3patch

1636ffa: The `pricing-plans` demo cards that linked to `/contact` now use the caller's `placeholderHref` or `#`, like the other cards.

  • 1636ffa: The `pricing-plans` demo cards that linked to `/contact` now use the caller's `placeholderHref` or `#`, like the other cards.
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.43.1patch

c1f7242: `cta` / `doors`: the closing links ("Talk to us about your platform", "Browse the block library" in the starter) now have a 44px-plus tap target. The underlined link is about 29px tall; a transparent hit area, centered on it and at least 48px tall, extends it without moving the underline or the layout.

  • c1f7242: `cta` / `doors`: the closing links ("Talk to us about your platform", "Browse the block library" in the starter) now have a 44px-plus tap target. The underlined link is about 29px tall; a transparent hit area, centered on it and at least 48px tall, extends it without moving the underline or the layout.
v0.42.1patch

82a2631: `testimonial` now exposes public part hooks and gives its carousel dots a 44px hit area. New attributes, in every layout: `data-testimonial-quote` (the blockquote), `-cite` (the attribution wrapper), `-name`, `-role`, `-media` (the avatar or portrait wrapper) and, in the carousel, `-dots` on the pagination nav with `-dot` on each dot button. The attributes are additive and the block's stored-render snapshots were updated for them; removing them gives the previous markup exactly. The dots keep their drawn size and spacing and gain an `::after` hit area, 44px tall and as wide as the dot plus the gap, so neighboring areas tile without overlapping; the gap is exposed as `--tome-testimonial-dot-gap` (default unchanged), which a theme can widen for full 44×44 targets.

  • 82a2631: `testimonial` now exposes public part hooks and gives its carousel dots a 44px hit area. New attributes, in every layout: `data-testimonial-quote` (the blockquote), `-cite` (the attribution wrapper), `-name`, `-role`, `-media` (the avatar or portrait wrapper) and, in the carousel, `-dots` on the pagination nav with `-dot` on each dot button. The attributes are additive and the block's stored-render snapshots were updated for them; removing them gives the previous markup exactly. The dots keep their drawn size and spacing and gain an `::after` hit area, 44px tall and as wide as the dot plus the gap, so neighboring areas tile without overlapping; the gap is exposed as `--tome-testimonial-dot-gap` (default unchanged), which a theme can widen for full 44×44 targets.
v0.39.0minor

af33c85: `faq` can group its questions, set its closing callout beside them, and change the callout by the hour. Three optional fields, none with a default: - `faqs[].group`: files a question under a heading. Questions sharing a label are gathered under one `<h3 data-faq-group-title>` in a `data-faq-group` wrapper, in the order the labels first appear; questions with no label form a group of their own. With no group set the list stays flat. - `calloutPlacement: 'aside'`: the callout becomes an `<aside>` card (`data-faq-callout-placement="aside"`) beside the questions as a sticky card from 1024px, and stays under them on phones. - `calloutTextOpen` and `calloutTextClosed`: rich-text copies of the callout, marked `data-when-open` and `data-when-closed` beside a neutral `data-when-neutral` copy from `calloutText`. The block's stylesheet chooses by `<html data-tome-open="true|false">`, the attribute chrome writes from the site's hours; with it absent the neutral copy shows, so a site without hours never reads "closed". A side left empty falls back to `calloutText`. Stored documents that set none of the new fields render byte-identical markup (pinned by a golden test). Purely additive: no peer is added or tightened, no field has a default, and nothing changes for a site that sets none of them.

  • af33c85: `faq` can group its questions, set its closing callout beside them, and change the callout by the hour. Three optional fields, none with a default: - `faqs[].group`: files a question under a heading. Questions sharing a label are gathered under one `<h3 data-faq-group-title>` in a `data-faq-group` wrapper, in the order the labels first appear; questions with no label form a group of their own. With no group set the list stays flat. - `calloutPlacement: 'aside'`: the callout becomes an `<aside>` card (`data-faq-callout-placement="aside"`) beside the questions as a sticky card from 1024px, and stays under them on phones. - `calloutTextOpen` and `calloutTextClosed`: rich-text copies of the callout, marked `data-when-open` and `data-when-closed` beside a neutral `data-when-neutral` copy from `calloutText`. The block's stylesheet chooses by `<html data-tome-open="true|false">`, the attribute chrome writes from the site's hours; with it absent the neutral copy shows, so a site without hours never reads "closed". A side left empty falls back to `calloutText`. Stored documents that set none of the new fields render byte-identical markup (pinned by a golden test). Purely additive: no peer is added or tightened, no field has a default, and nothing changes for a site that sets none of them.
  • 45db383: `faq` can show a second link after its callout link. `calloutSecondLink { label, url }` (optional, no default) renders a quieter text link after the callout link, marked `data-faq-callout-second`, for a second destination such as "All parent questions". It shows only with both a label and a link, and a `javascript:`, `data:` or `vbscript:` link is ignored. The label is localized. A block that does not set it renders exactly the markup it did before. Purely additive: no peer is added or tightened.
v0.38.0minor

06221cd: The split `high-impact-hero` can turn a supporting image's caption into a chip with an add-to-bag button, and show its proof line as separate facts. **BREAKING:** the `@wabbit/tome-blocks-core` peer range is now `>=0.38.0 <1.0.0`, because the chip renders through the new `AddToBag` adapter. **Migration:** upgrade `@wabbit/tome-blocks-core` to 0.38.0 or later. The new fields add database columns: run your Payload migration and regenerate types. New optional fields, shown for the split variant only and empty by default, so stored heroes render unchanged: `supportingImages[].shopAction` (`product` slug, `href`, `variantSku`, `label`) and `proofLine.segments[]`. With a `shopAction` the caption becomes `figcaption[data-hero-caption-chip]` holding the caption and a `span[data-hero-caption-action]` that wraps the `AddToBag` control (a plain link to `href` until a commerce layer registers the real one; the wrapper is empty when there is nothing to link to). Segments render as `span[data-hero-proof-segment]` in place of the proof line's text. The chip wraps the caption name in `strong[data-hero-caption-name]`. A new optional split-only `eyebrowLead` text renders `b[data-hero-eyebrow-lead]` before the eyebrow ("**Batch 14** · out of the kiln"); empty renders the eyebrow unchanged.

  • 06221cd: The split `high-impact-hero` can turn a supporting image's caption into a chip with an add-to-bag button, and show its proof line as separate facts. **BREAKING:** the `@wabbit/tome-blocks-core` peer range is now `>=0.38.0 <1.0.0`, because the chip renders through the new `AddToBag` adapter. **Migration:** upgrade `@wabbit/tome-blocks-core` to 0.38.0 or later. The new fields add database columns: run your Payload migration and regenerate types. New optional fields, shown for the split variant only and empty by default, so stored heroes render unchanged: `supportingImages[].shopAction` (`product` slug, `href`, `variantSku`, `label`) and `proofLine.segments[]`. With a `shopAction` the caption becomes `figcaption[data-hero-caption-chip]` holding the caption and a `span[data-hero-caption-action]` that wraps the `AddToBag` control (a plain link to `href` until a commerce layer registers the real one; the wrapper is empty when there is nothing to link to). Segments render as `span[data-hero-proof-segment]` in place of the proof line's text. The chip wraps the caption name in `strong[data-hero-caption-name]`. A new optional split-only `eyebrowLead` text renders `b[data-hero-eyebrow-lead]` before the eyebrow ("**Batch 14** · out of the kiln"); empty renders the eyebrow unchanged.
  • 36adebe: The `testimonial` block's `quotes-grid` layout gains an opt-in portrait. The new checkbox `showPortraits` (shown for `quotes-grid` only, off by default) renders a quote's image as a round portrait, `div[data-testimonial-portrait]`, at the start of its attribution, with the name and role beside it; a quote with an image and no name still gets a caption. The portrait is sized by `--tome-testimonial-portrait-size`. The images were already stored (`entries[].image`) but the grid never showed them, so with the option off, unset or false the grid, and every other layout, render exactly as before.
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
  • f9fe8fe: Feature Hero shows icons instead of their names, and four blocks stay inside a phone-width window. - **Feature Hero:** a feature's `icon` is a lucide name ("shield-check") or an emoji, but the renderer printed the name as text in the card. It now uses extras' shared `resolveLucideIcon` (`./render/shared`, which extras has exported since 0.28.0), the same resolver extras and catalog-pack blocks use. An unknown name shows no icon, and emoji stay text. The extras map gains `shield-check` and `workflow`. - **Feature Hero grids:** the authored two- and three-column feature grids kept their columns on a phone and ran 380px past the window. Tracks are now `minmax(0, 1fr)`, and the grids stack below 640px. In a card with its image beside the text, the text drops below the image when it would get less than 10rem, instead of breaking words mid-way or pushing the card wider. - **Status Board:** the same fix to its card grid. It was 24px past its box at 375px and now stacks below 640px. - **Editorial Colophon:** its `3 / 11` placement counts the page grid's 16 desktop tracks, so below 1024px it overran into the right gutter. It sits on the content lines there and keeps its desktop placement. - **FAQ:** Full Width is padded by the page gutter, like Feature Hero's and CTA's Full Width.
v0.37.0minor

0e56651: `high-impact-hero` `split` gains a media side, a proof line, a motif plate and theme hooks. `mediaSide` (`start` | `end`, empty reads as `end`) moves the supporting images before or after the copy. The `proofLine` group, which only `quick-ask` showed, is now also shown on `split`, as one line under the buttons. An optional `motif` (`image`, `alt`; alt text required when an image is set) renders as a small plate beside the proof line. `split` also writes neutral hooks for themes: `data-hero-media-side`, `data-hero-copy`, `data-hero-subheadline`, `data-hero-media`, `data-hero-foot`, `data-hero-proof`, `data-hero-proof-lead` and `data-hero-motif`. Every new field is optional with no default; the other variants render byte-identically, and a `split` block differs from before only by the added data attributes. `testimonial` gains an optional `titleLevel` (`h2` | `h3`, used with the custom section title) and `faq` an optional `calloutAfter` text. - **`testimonial` `titleLevel`**: the custom section title renders as an `h3` today. A block that opens its section with no heading above it can set `titleLevel` to `h2` to emit an `h2` instead. Empty, `h3` and any other value render the `h3` the block always rendered, byte for byte. - **`faq` `calloutAfter`**: a short line (for example "I read every one.") rendered after the callout link as `<span data-faq-callout-after>`, inside the callout. Empty or blank renders nothing and the markup is unchanged. - Payload migration: both are new optional fields (a select and a localized text), so Postgres sites that register these blocks should run `payload migrate:create` and `payload migrate` after upgrading.

  • 0e56651: `high-impact-hero` `split` gains a media side, a proof line, a motif plate and theme hooks. `mediaSide` (`start` | `end`, empty reads as `end`) moves the supporting images before or after the copy. The `proofLine` group, which only `quick-ask` showed, is now also shown on `split`, as one line under the buttons. An optional `motif` (`image`, `alt`; alt text required when an image is set) renders as a small plate beside the proof line. `split` also writes neutral hooks for themes: `data-hero-media-side`, `data-hero-copy`, `data-hero-subheadline`, `data-hero-media`, `data-hero-foot`, `data-hero-proof`, `data-hero-proof-lead` and `data-hero-motif`. Every new field is optional with no default; the other variants render byte-identically, and a `split` block differs from before only by the added data attributes. `testimonial` gains an optional `titleLevel` (`h2` | `h3`, used with the custom section title) and `faq` an optional `calloutAfter` text. - **`testimonial` `titleLevel`**: the custom section title renders as an `h3` today. A block that opens its section with no heading above it can set `titleLevel` to `h2` to emit an `h2` instead. Empty, `h3` and any other value render the `h3` the block always rendered, byte for byte. - **`faq` `calloutAfter`**: a short line (for example "I read every one.") rendered after the callout link as `<span data-faq-callout-after>`, inside the callout. Empty or blank renders nothing and the markup is unchanged. - Payload migration: both are new optional fields (a select and a localized text), so Postgres sites that register these blocks should run `payload migrate:create` and `payload migrate` after upgrading.
v0.36.3patch

1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.

  • 1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.
v0.36.0patch

2e9f1ab: Phone links in the contact, high-impact-hero and local-logistics code now use the shared core `tel:` helper, so a site can qualify national numbers to E.164. The exported `toTelHref` helpers (and `resolveContactValueLink`, `resolveInlineCall`) accept an optional `{ defaultCallingCode }`. The high-impact-hero reads `defaultCallingCode` from the render context passed to `RenderBlocks({ context })`; without it, hrefs stay digits-only as before. Requires `@wabbit/tome-core` 1.22.0 or later as a peer.

  • 2e9f1ab: Phone links in the contact, high-impact-hero and local-logistics code now use the shared core `tel:` helper, so a site can qualify national numbers to E.164. The exported `toTelHref` helpers (and `resolveContactValueLink`, `resolveInlineCall`) accept an optional `{ defaultCallingCode }`. The high-impact-hero reads `defaultCallingCode` from the render context passed to `RenderBlocks({ context })`; without it, hrefs stay digits-only as before. Requires `@wabbit/tome-core` 1.22.0 or later as a peer.
v0.35.0minor

f4a08ca: **BREAKING:** these packs now need `@wabbit/tome-blocks-core` 0.35.0 or later, because they read the wrapper's new `data-block-placement` instead of its inline style text. **Migration:** upgrade `@wabbit/tome-blocks-core` to 0.35.0 or later alongside these packs. A site that renders its own block wrapper (with `data-tome-block-wrapper`) on a span other than `1 / -1` should add `data-block-placement={blockPlacementFor(span)}` to it; a wrapper without the attribute is read as full width. - `@wabbit/tome-blocks-local-pack`: in Safari, the price list's teaser list and the logistics block's content touched the viewport edges once the page had scrolled. Their side padding depended on matching the wrapper's `style` text, which WebKit rewrites as `grid-column-start`/`grid-column-end` longhands as soon as the scroll reveal writes to it. The padding rule now reads `data-block-placement`. Chromium and Firefox render as before. - `@wabbit/tome-blocks-house`: `LaneGrid` decides whether to subgrid its block wrapper (full row, content column, or its own tracks for any other span) from `data-block-placement`, so the decision no longer changes in Safari after the scroll reveal runs. - `@wabbit/tome-blocks-marketing-starter`: `exhibit`'s nested block wrappers carry `data-block-placement`, matching `RenderBlocks`.

  • f4a08ca: **BREAKING:** these packs now need `@wabbit/tome-blocks-core` 0.35.0 or later, because they read the wrapper's new `data-block-placement` instead of its inline style text. **Migration:** upgrade `@wabbit/tome-blocks-core` to 0.35.0 or later alongside these packs. A site that renders its own block wrapper (with `data-tome-block-wrapper`) on a span other than `1 / -1` should add `data-block-placement={blockPlacementFor(span)}` to it; a wrapper without the attribute is read as full width. - `@wabbit/tome-blocks-local-pack`: in Safari, the price list's teaser list and the logistics block's content touched the viewport edges once the page had scrolled. Their side padding depended on matching the wrapper's `style` text, which WebKit rewrites as `grid-column-start`/`grid-column-end` longhands as soon as the scroll reveal writes to it. The padding rule now reads `data-block-placement`. Chromium and Firefox render as before. - `@wabbit/tome-blocks-house`: `LaneGrid` decides whether to subgrid its block wrapper (full row, content column, or its own tracks for any other span) from `data-block-placement`, so the decision no longer changes in Safari after the scroll reveal runs. - `@wabbit/tome-blocks-marketing-starter`: `exhibit`'s nested block wrappers carry `data-block-placement`, matching `RenderBlocks`.
v0.34.1patch

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

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

e8b4d32: testimonial `quotes-grid` and faq gain the options a theme needs. - **testimonial `quotes-grid`:** new optional `eyebrow`, a small section label above the section title (`<p data-block-eyebrow>`); the block had no label field, so a "Client voice" label had nowhere to go. The grid's column count is the token `--tome-testimonial-grid-cols` (defaults unchanged: 1, 2 from 640px, 3 from 1024px). The stat value supports the `*asterisk*` accent convention: "9 _in_ 10" renders "in" as `<em data-testimonial-stat-unit>`, upright and smaller (`--tome-testimonial-stat-unit-size`, default `0.5em`). A value with no stars renders as before. - **faq:** new `calloutStyle` (`box` by default, or `line`). `line` sets the closing "still have a question?" bridge as a plain start-aligned line with a text link and marks it `data-faq-callout-style="line"`. The bridge and its link carry `data-faq-callout` and `data-faq-callout-link`, and the header group and badge carry `data-block-header` and `data-block-eyebrow`, so a theme can restyle the badge chip.

  • e8b4d32: testimonial `quotes-grid` and faq gain the options a theme needs. - **testimonial `quotes-grid`:** new optional `eyebrow`, a small section label above the section title (`<p data-block-eyebrow>`); the block had no label field, so a "Client voice" label had nowhere to go. The grid's column count is the token `--tome-testimonial-grid-cols` (defaults unchanged: 1, 2 from 640px, 3 from 1024px). The stat value supports the `*asterisk*` accent convention: "9 _in_ 10" renders "in" as `<em data-testimonial-stat-unit>`, upright and smaller (`--tome-testimonial-stat-unit-size`, default `0.5em`). A value with no stars renders as before. - **faq:** new `calloutStyle` (`box` by default, or `line`). `line` sets the closing "still have a question?" bridge as a plain start-aligned line with a text link and marks it `data-faq-callout-style="line"`. The bridge and its link carry `data-faq-callout` and `data-faq-callout-link`, and the header group and badge carry `data-block-header` and `data-block-eyebrow`, so a theme can restyle the badge chip.
  • 3e4f0a7: Narrow-screen rules that never fired now fire. These blocks wrote their phone layout as an unnamed `@container` query, but nothing around them declares a size container (not tome-ui, the blocks core or the starter), so the rules never matched: the `high-impact-hero` `split` and default layouts never stacked on phones. Declaring the container on the block root would not work either, because size containment turns a `subgrid` root into a plain grid and drops the page-grid lines its children sit on. The rules are viewport queries now, at the same breakpoints; the blocks are full-bleed, so the viewport is their width. - `@wabbit/tome-blocks-marketing-starter`: `high-impact-hero` (two columns stack to one, large headline sizes step down, at 768px and below). - `@wabbit/tome-blocks-extras`: `BlogHero`, `ChapterHero`, `CustomHero`, `ImageHero`, `LowImpactHero`, `MediumImpactHero`, `PageHero`, `StudyHero`, `TypographyHero` (768px) and `SectionHeader` (640px). - `@wabbit/tome-blocks-content-writer`: `PostHero` (768px). - `@wabbit/tome-blocks-agency-essentials`: `SplitView` stacks at 768px. Above those widths nothing changes.
v0.33.0minor

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

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

072f973: New optional fields and theme hooks: an inline call link on the split hero, image labels, a quotes-grid testimonial layout, an open-first FAQ, and a stat-strip note. **marketing-starter, `high-impact-hero`:** the `split` variant now shows `callAction` (`label`, `phone`) and renders it as an inline `tel:` link after the buttons, for example "or call (555) 010-2030" (`data-hero-call`). An empty label reads "or call" after buttons and "Call" without them. In every variant that shows supporting images, `supportingImages[]` gains `label`, a short tag set over the image such as "Illustrative photo" (`data-compliance-label`), and `captionMeta`, a smaller second caption line (`data-caption-meta`). Every variant that renders actions marks its action row `data-hero-actions` (quick-ask already did): the row holding the buttons and the inline call on `split`, otherwise the button row itself. The attribute is the only markup change. **marketing-starter, `testimonial`:** each entry gains an optional `complianceLabel`, shown with its quote in every layout (`data-compliance-label`). The `layout` select adds `quotes-grid`: every testimonial becomes a short quote in a responsive grid, with an optional `stat` tile (`value`, `text`, `label`, `position`) placed at its 1-based cell among the quotes, default 2. Hooks: `data-testimonial-layout="quotes-grid"`, `data-testimonial-cell` on every cell, `data-testimonial-stat` on the tile, `data-testimonial-stat-value` on its value. **marketing-starter, `faq`:** an `openFirst` checkbox (default off) renders the first question open and marks the root with `data-faq-open-first`. Every FAQ now carries part hooks: `data-faq-item` on each `<details>`, `data-faq-question` on its `<summary>`, and `data-faq-answer` on a new plain `<div>` around each answer (the answer's own element comes from your rich-text adapter, which accepts only a class name). **proposal-pack, `proposal-stat-strip`:** an optional `note` renders a source or compliance line under the figures (`data-stat-note`), with the same tokens as the stats. When the section header does not show, the root now carries `data-stat-strip-headless`; nothing renders in the header's place, so no gap is left above the figures. Every new field is optional, and the rendered markup is unchanged while they are empty; the additions to existing output are the FAQ part-hook attributes, the unstyled answer wrapper, the `data-hero-actions` attribute on hero action rows, and the `data-stat-strip-headless` attribute on stat strips that already had no header. The new fields add columns, so run your Payload migration and regenerate types. **extras:** the shared `HeroLinkList` accepts an optional `containerData` (extra `data-*` attributes for its row element). Omitted, the row renders exactly as before.

  • 072f973: New optional fields and theme hooks: an inline call link on the split hero, image labels, a quotes-grid testimonial layout, an open-first FAQ, and a stat-strip note. **marketing-starter, `high-impact-hero`:** the `split` variant now shows `callAction` (`label`, `phone`) and renders it as an inline `tel:` link after the buttons, for example "or call (555) 010-2030" (`data-hero-call`). An empty label reads "or call" after buttons and "Call" without them. In every variant that shows supporting images, `supportingImages[]` gains `label`, a short tag set over the image such as "Illustrative photo" (`data-compliance-label`), and `captionMeta`, a smaller second caption line (`data-caption-meta`). Every variant that renders actions marks its action row `data-hero-actions` (quick-ask already did): the row holding the buttons and the inline call on `split`, otherwise the button row itself. The attribute is the only markup change. **marketing-starter, `testimonial`:** each entry gains an optional `complianceLabel`, shown with its quote in every layout (`data-compliance-label`). The `layout` select adds `quotes-grid`: every testimonial becomes a short quote in a responsive grid, with an optional `stat` tile (`value`, `text`, `label`, `position`) placed at its 1-based cell among the quotes, default 2. Hooks: `data-testimonial-layout="quotes-grid"`, `data-testimonial-cell` on every cell, `data-testimonial-stat` on the tile, `data-testimonial-stat-value` on its value. **marketing-starter, `faq`:** an `openFirst` checkbox (default off) renders the first question open and marks the root with `data-faq-open-first`. Every FAQ now carries part hooks: `data-faq-item` on each `<details>`, `data-faq-question` on its `<summary>`, and `data-faq-answer` on a new plain `<div>` around each answer (the answer's own element comes from your rich-text adapter, which accepts only a class name). **proposal-pack, `proposal-stat-strip`:** an optional `note` renders a source or compliance line under the figures (`data-stat-note`), with the same tokens as the stats. When the section header does not show, the root now carries `data-stat-strip-headless`; nothing renders in the header's place, so no gap is left above the figures. Every new field is optional, and the rendered markup is unchanged while they are empty; the additions to existing output are the FAQ part-hook attributes, the unstyled answer wrapper, the `data-hero-actions` attribute on hero action rows, and the `data-stat-strip-headless` attribute on stat strips that already had no header. The new fields add columns, so run your Payload migration and regenerate types. **extras:** the shared `HeroLinkList` accepts an optional `containerData` (extra `data-*` attributes for its row element). Omitted, the row renders exactly as before.
v0.31.0minor

5f2662c: Groundwork v1.1 block fields for the quick-ask hero and the estimate contact band. **`high-impact-hero` `quick-ask`:** `proofLine` gains an optional `proofLead` text field, rendered as a `<strong data-hero-proof-lead>` before the proof text (for example "Next opening:"). Fixed: at phone widths the status dot could wrap onto its own line away from its text. The proof line is now inline flow, and the dot shares a no-wrap run (`data-hero-proof-start`) with its first phrase: the lead, or the first word of the text. The rest of the line wraps normally. With no lead and no dot, the markup is unchanged. **`contact` `estimate`:** two optional text fields. `eyebrow` is a small meta-voice kicker above the heading (`data-contact-eyebrow`). `formNote` is a short meta line beside the form card heading (`data-contact-form-note`). When it is set, the heading and note share a wrapping head row (`data-contact-card-head`). When both fields are empty, the output is unchanged.

  • 5f2662c: Groundwork v1.1 block fields for the quick-ask hero and the estimate contact band. **`high-impact-hero` `quick-ask`:** `proofLine` gains an optional `proofLead` text field, rendered as a `<strong data-hero-proof-lead>` before the proof text (for example "Next opening:"). Fixed: at phone widths the status dot could wrap onto its own line away from its text. The proof line is now inline flow, and the dot shares a no-wrap run (`data-hero-proof-start`) with its first phrase: the lead, or the first word of the text. The rest of the line wraps normally. With no lead and no dot, the markup is unchanged. **`contact` `estimate`:** two optional text fields. `eyebrow` is a small meta-voice kicker above the heading (`data-contact-eyebrow`). `formNote` is a short meta line beside the form card heading (`data-contact-form-note`). When it is set, the heading and note share a wrapping head row (`data-contact-card-head`). When both fields are empty, the output is unchanged.
  • b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.
v0.30.0minor

fd848e0: **BREAKING:** the `high-impact-hero` `quick-ask` call action now shows on phones only, unless the new `callAction.showOnDesktop` checkbox is ticked. **Migration:** to keep the call action on screens 768px and wider, tick **Show on desktop** on the hero's Call Action (or set `callAction.showOnDesktop: true`). Phones are unchanged: the call still leads the actions there. The call link carries `data-hero-call-desktop="true|false"` so a theme can tell the two apart.

  • fd848e0: **BREAKING:** the `high-impact-hero` `quick-ask` call action now shows on phones only, unless the new `callAction.showOnDesktop` checkbox is ticked. **Migration:** to keep the call action on screens 768px and wider, tick **Show on desktop** on the hero's Call Action (or set `callAction.showOnDesktop: true`). Phones are unchanged: the call still leads the actions there. The call link carries `data-hero-call-desktop="true|false"` so a theme can tell the two apart.
v0.29.0minor

698c314: High Impact Hero gains a `quick-ask` variant: text left, media right, with a one-question form, a tap-to-call action and a proof line. Additive: the three new groups are optional, carry no defaults and appear in the admin only when `quick-ask` is selected, so existing heroes render as before. Adding the fields to a Payload config adds their columns, so run your usual migration. - **`quickAsk`** (`label`, `options[]` of `label` + `value`, `submitLabel`, `action`, `paramName`). Renders a plain GET form with a visible label and a real `<select>`, so it works without JavaScript. The selected value is sent as `?<paramName>=<value>` (default `need`) to the action, a URL or `#anchor` (default `#contact`); params already in the action are kept as hidden inputs. - **`callAction`** (`label`, `phone`). A `tel:` link. It is the first action on phones (below 768px) and follows the form on wider screens. - **`proofLine`** (`text`, `showDot`). One short line under the actions, with an optional status dot. - The job caption reuses `supportingImages[].caption`. - Theme hooks: `data-block-variant="quick-ask"`, `data-hero-actions`, `data-hero-call`, `data-hero-quick-ask`, `data-hero-proof`, `data-status-dot`, `data-hero-media`, `data-hero-caption`. Every hero variant root now also carries `data-block-variant`.

  • 698c314: High Impact Hero gains a `quick-ask` variant: text left, media right, with a one-question form, a tap-to-call action and a proof line. Additive: the three new groups are optional, carry no defaults and appear in the admin only when `quick-ask` is selected, so existing heroes render as before. Adding the fields to a Payload config adds their columns, so run your usual migration. - **`quickAsk`** (`label`, `options[]` of `label` + `value`, `submitLabel`, `action`, `paramName`). Renders a plain GET form with a visible label and a real `<select>`, so it works without JavaScript. The selected value is sent as `?<paramName>=<value>` (default `need`) to the action, a URL or `#anchor` (default `#contact`); params already in the action are kept as hidden inputs. - **`callAction`** (`label`, `phone`). A `tel:` link. It is the first action on phones (below 768px) and follows the form on wider screens. - **`proofLine`** (`text`, `showDot`). One short line under the actions, with an optional status dot. - The job caption reuses `supportingImages[].caption`. - Theme hooks: `data-block-variant="quick-ask"`, `data-hero-actions`, `data-hero-call`, `data-hero-quick-ask`, `data-hero-proof`, `data-status-dot`, `data-hero-media`, `data-hero-caption`. Every hero variant root now also carries `data-block-variant`.
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.28.0patch

483e0a1: The marketing starter's preview data now uses invented brand and client names throughout. Hero, pricing, logo and testimonial previews swap two brand names for fictional ones. Block fields and variants are unchanged.

  • 483e0a1: The marketing starter's preview data now uses invented brand and client names throughout. Hero, pricing, logo and testimonial previews swap two brand names for fictional ones. Block fields and variants are unchanged.
  • 868c087: The logo slider and the high-impact hero's marquee strip can now be paused by hover, keyboard focus, touch tap and a pause button that appears on keyboard focus. On the logo slider, hovering or focusing a logo link or tapping the band pauses the scroll, and the pause button comes before the logo links in tab order. On the hero, the marquee strip gets the same pause button and pause behavior. Under `prefers-reduced-motion: reduce` neither moves. Needs `@wabbit/tome-blocks-extras` with the `MotionPauseControl` export.
v0.27.0minor

c14a133: The pricing and testimonial blocks work without collections of their own: by default their cards and testimonials are entered on the block. **Pricing.** The block used to relate to a `product-cards` collection, which a site that only installed this pack doesn't have, so Payload refused to start once the block was registered. With no `productCardsCollection` option, the block now has two card lists (`subscriptionCards`, `projectCards`) with the fields the cards render: title, description, tier, badge, price, frequency, a custom-quote switch, features, and button text and link. Passing `productCardsCollection` keeps the relationship fields (`selectedSubscriptionCards`, `selectedProjectCards`) exactly as before. The renderer reads either. **Testimonial.** The block used to relate to a `testimonials` collection, with the same startup failure on a site without one. With no `testimonialsCollection` option it now has an `entries` list (quote, name, role, company, image, date): single mode shows the first, carousel shows them all. Passing `testimonialsCollection` keeps the `testimonial` / `testimonials` relationships exactly as before. **BREAKING:** a site that used either block with its old default must now pass the collection to keep its schema and stored data: `productCardsCollection: 'product-cards'` for pricing, `testimonialsCollection: 'testimonials'` for testimonial.

  • c14a133: The pricing and testimonial blocks work without collections of their own: by default their cards and testimonials are entered on the block. **Pricing.** The block used to relate to a `product-cards` collection, which a site that only installed this pack doesn't have, so Payload refused to start once the block was registered. With no `productCardsCollection` option, the block now has two card lists (`subscriptionCards`, `projectCards`) with the fields the cards render: title, description, tier, badge, price, frequency, a custom-quote switch, features, and button text and link. Passing `productCardsCollection` keeps the relationship fields (`selectedSubscriptionCards`, `selectedProjectCards`) exactly as before. The renderer reads either. **Testimonial.** The block used to relate to a `testimonials` collection, with the same startup failure on a site without one. With no `testimonialsCollection` option it now has an `entries` list (quote, name, role, company, image, date): single mode shows the first, carousel shows them all. Passing `testimonialsCollection` keeps the `testimonial` / `testimonials` relationships exactly as before. **BREAKING:** a site that used either block with its old default must now pass the collection to keep its schema and stored data: `productCardsCollection: 'product-cards'` for pricing, `testimonialsCollection: 'testimonials'` for testimonial.
  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.24.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 10 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - FeatureHero, HighImpactHero, LogoSlider, PricingPlans and Testimonial (client components) are now server-safe wrappers (`X.tsx`) around `X.client.tsx`; PricingPlanCard imports PricingPlans' stylesheet for class names. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 10 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - FeatureHero, HighImpactHero, LogoSlider, PricingPlans and Testimonial (client components) are now server-safe wrappers (`X.tsx`) around `X.client.tsx`; PricingPlanCard imports PricingPlans' stylesheet for class names. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.20.1patch

cc6c65e: Fixes a badge/eyebrow overlap on narrow cards and two layout/contrast issues in the marketing-starter CTA and exhibit blocks. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's badge no longer overlaps the eyebrow text on narrow cards — below roughly 30rem of available width it drops out of its absolute-positioned corner and flows in-line above the eyebrow as a chip instead. `@wabbit/tome-blocks-marketing-starter`: the `cta` block's `doors` variant fixes a low-contrast underline on its "toolkit"-voice link, which was drawing its border from a surface-tint accent color instead of the paired text-safe accent token. The `exhibit` block's `gallery-wall` variant increases the vertical gap between its staggered plates at desktop widths so they no longer visually touch, and now centers the third plate on the grid when a wall has exactly three exhibits instead of leaving it flush left.

  • cc6c65e: Fixes a badge/eyebrow overlap on narrow cards and two layout/contrast issues in the marketing-starter CTA and exhibit blocks. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's badge no longer overlaps the eyebrow text on narrow cards — below roughly 30rem of available width it drops out of its absolute-positioned corner and flows in-line above the eyebrow as a chip instead. `@wabbit/tome-blocks-marketing-starter`: the `cta` block's `doors` variant fixes a low-contrast underline on its "toolkit"-voice link, which was drawing its border from a surface-tint accent color instead of the paired text-safe accent token. The `exhibit` block's `gallery-wall` variant increases the vertical gap between its staggered plates at desktop widths so they no longer visually touch, and now centers the third plate on the grid when a wall has exactly three exhibits instead of leaving it flush left.
v0.18.3patch

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behavior change, and the published `dist/` is identical.
  • 786853e: The pricing block's catalog slug is now `pricing-plans`, so gallery and inserter URLs keyed on `pricing` change to `pricing-plans`. The render registry turns kebab slugs into camelCase, so `pricing-plans` now resolves to the Payload block `pricingPlans` without an alias. Stored content is untouched: `blockType` stays `pricingPlans`, and the `./blocks/pricing` subpath export keeps its name. `getDemoProps` answers both `pricing-plans` and `pricing`. Update any hard-coded `/blocks/.../pricing` links, and every `pairsWith` / `sequence` reference inside the pack now names `pricing-plans`.
  • 786853e: `logo-slider`'s Static Grid variant now renders a still, wrapping grid of logos, and Marquee Editorial now scrolls slower, wider and muted. Before, both variants rendered the default marquee and only set `data-variant`. Static Grid mounts no marquee. Logo Size keeps its marquee heights, Logo Spacing becomes the gap, and a logo with a URL is a real link. Marquee Editorial runs at half the Scroll Speed with 1.5× the Logo Spacing, at 60% opacity. Documents already on these variants change appearance to match their labels. That is a fix, and no consumer action is needed, so this is a patch. The `cta`, `faq`, `high-impact-hero`, `exhibit` and `logo-slider` descriptions now say which variants are `data-variant` hooks for site or theme CSS, matching `banner` and `feature-hero`.
v0.18.1patch

c8827e7: Internal refactor: collection slugs are now typed through one package-internal helper instead of inline casts scattered across the source. No API or behavior change.

  • c8827e7: Internal refactor: collection slugs are now typed through one package-internal helper instead of inline casts scattered across the source. No API or behavior change.
  • c193d4a: `banner` and `feature-hero` descriptions now say what their variants actually do. The variants set `data-variant` for site or theme CSS; the inline `style` / `variant` selects still pick the alert color and layout, and stay because stored documents carry them.
v0.18.0patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 04309f5: Fix a hydration mismatch in `Testimonial` and adopt the shared UTC-pinned date formatter across five renderers. `@wabbit/tome-blocks-marketing-starter`'s `Testimonial` is a `'use client'` component and rendered its publication date with a bare `new Date(iso).toLocaleDateString()`. That resolves against the RUNTIME locale and the RUNTIME time zone, so the server produced one string and the browser produced another and React reported a hydration mismatch on the block. The 2026-07-11 audit flagged it; it was still there on 2026-09-01. The same bare call sat in four server renderers — `blocks-editorial-pack`'s `MetricStrip` and `StatusBoard`, `blocks-agency-essentials`' `TeamRoster` (twice) and `Timeline`. No hydration mismatch there, but the rendered output changed with the deploy host's locale and offset, which is its own kind of wrong. All six call sites now use `formatDisplayDate` from `@wabbit/tome-blocks-core/utilities/formatDisplayDate` with an explicit `locale: 'en-US'`, a numeric `M/D/YYYY` `dateStyle` and `timeZone: 'UTC'`. The numeric shape is deliberate: it is byte-identical to what a US-locale runtime already produced, so this fixes the determinism without silently restyling anyone's dates. Every pack already declared `@wabbit/tome-blocks-core`, so no dependency changes. Shipped with its forcing function: a `no-restricted-syntax` rule in `eslint.config.mjs` warns on bare `toLocaleDateString`/`toLocaleString`/`toLocaleTimeString` in every `packages/*/src/**/*.tsx` and every block pack's `render/` directory, and points at `formatDisplayDate` and at `blocks-gallery`'s `ADDED_AT_FORMATTER` as the two accepted shapes.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0 - @wabbit/tome-blocks-extras@0.16.0
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
  • Updated dependencies [54ff357] - @wabbit/tome-blocks-extras@0.15.12
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<color> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
  • Updated dependencies [1bebcdc]
  • Updated dependencies [48773ac] - @wabbit/tome-blocks-extras@0.15.11
v0.15.6patch

ddb50a0: Design-wave fixes (2026-08-09 mockup, section 2): exhibit gallery-wall plates now size to their nested content — `align-items: start` on the wall grid and no `min-height`/flex-fill on the canvas, with generous space-3xl/space-2xl padding — instead of stretching to the row's tallest neighbor and centering a small render in ~50% empty frame. The high-impact-hero `dark` variant demo now supplies `backgroundColorBehindImage: var(--tome-color-surface-solid-dark)` so the gallery preview shows its light text on a dark plate rather than near-white-on-white when the placeholder media is light or unresolved.

  • ddb50a0: Design-wave fixes (2026-08-09 mockup, section 2): exhibit gallery-wall plates now size to their nested content — `align-items: start` on the wall grid and no `min-height`/flex-fill on the canvas, with generous space-3xl/space-2xl padding — instead of stretching to the row's tallest neighbor and centering a small render in ~50% empty frame. The high-impact-hero `dark` variant demo now supplies `backgroundColorBehindImage: var(--tome-color-surface-solid-dark)` so the gallery preview shows its light text on a dark plate rather than near-white-on-white when the placeholder media is light or unresolved.
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-extras@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0
v0.13.0minor

f4d55c9: cta: `door-strip` + `doors` variants graduated from tome-starter (showcase program Phase 3.7). Adds a `doors` array field (min/max 2 rows: voice, kicker, heading, body, link) and a `columnSpan` field to the base cta schema — both additive, backward-compatible. `door-strip` is a compact two-door router (whole-surface links, mono kicker, serif line, arrow affordance); `doors` is a full two-door close (kicker, serif heading, body, underlined go-link per door, scroll-reveal via the new `@wabbit/tome-blocks-core` `Reveal` helper — seed with `columnSpan: '1 / -1'` for full bleed). Both are style-only variants: the `doors` field lives on the base schema behind an `admin.condition`, not a `fieldOverrides` schema variant, because per-document schema divergence can't be expressed at config-build time (see comment in `cta/variants.ts`).

  • f4d55c9: cta: `door-strip` + `doors` variants graduated from tome-starter (showcase program Phase 3.7). Adds a `doors` array field (min/max 2 rows: voice, kicker, heading, body, link) and a `columnSpan` field to the base cta schema — both additive, backward-compatible. `door-strip` is a compact two-door router (whole-surface links, mono kicker, serif line, arrow affordance); `doors` is a full two-door close (kicker, serif heading, body, underlined go-link per door, scroll-reveal via the new `@wabbit/tome-blocks-core` `Reveal` helper — seed with `columnSpan: '1 / -1'` for full bleed). Both are style-only variants: the `doors` field lives on the base schema behind an `admin.condition`, not a `fieldOverrides` schema variant, because per-document schema divergence can't be expressed at config-build time (see comment in `cta/variants.ts`).
  • 9116174: exhibit: new gallery-wall block graduated from tome-starter (showcase Phase 3.7) — museum plates framing real nested block renders; nested allowlist via factory config (exhibitBlocks). Ports the starter's `src/blocks/Exhibit/` (config.ts, Component.tsx, Component.module.css) into a new `exhibit` block: an asymmetric 12-col wall of hairline-framed white canvases, each holding a REAL nested block render (not a screenshot) under a plate caption (title + pack attribution). One style-only variant (`gallery-wall`), registered via plain `defineBlock(meta, factory, variants)` matching `testimonialBlock`'s idiom. The nested `blocks` allowlist is a factory-config contract — pass `config.exhibitBlocks` (an array of Payload `Block` configs) when invoking `exhibitBlock.block(config)` to curate what's exhibitable; omitted, it defaults to this pack's own `testimonial` block so the field is never empty and the pack stays self-contained (no import of `lms`/`catalog`/`org` packs). The `Exhibit` render component takes an optional `nestedComponents` prop (registry-free resolution map for the nested per-exhibit blocks, checked before the shared `@wabbit/tome-blocks-core` render registry) and replicates `RenderBlocks`' per-block subgrid wrapper (`data-tome-block-wrapper`, `data-block-background`, `gridColumn`/`gridTemplateColumns: subgrid`) by hand around each `RenderBlock` call for DOM/CSS parity with the rest of the pack. Registered in both render paths: the legacy `'use client'` side-effect barrel (`./render`) and the server-safe explicit registry (`./render/register`).
  • Updated dependencies [f4d55c9]
  • Updated dependencies [eb403d4] - @wabbit/tome-blocks-core@0.13.0 - @wabbit/tome-blocks-extras@0.13.0
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2
v0.11.0patch

26dfa07: Pre-existing test-suite fixes (unrelated to recent feature work): - `high-impact-hero`'s `illustrationHero` variant carried two tags (`brand`, `illustration`) outside the canonical taxonomy declared in `v2-coverage.test.ts`. Fixed to `['editorial', 'playful']`. The `illustrationHero` slug itself is kept camelCase (not renamed to kebab-case) because it shipped in the published `0.5.0` release (2026-05-21) and is stored verbatim in consumer content as a `_variant` field value — renaming would silently break every stored document that already selected it. The kebab-case rule now carries a documented, slug-scoped exception with a deprecation trigger (next content-breaking major version for this package, when a stored-content migration pass is already budgeted). - `blocks-core`'s `test/top20-variants.test.ts` imported `@wabbit/tome-blocks-marketing-starter` and the other bundle packs directly, which a later refactor made unresolvable when it removed blocks-core's devDeps on those packs to break the blocks-core ↔ marketing-starter dependency cycle. Relocated the test to `@wabbit/tome-blocks` (the meta-package that already depends on every pack for exactly this purpose) rather than re-adding the removed devDeps and recreating the cycle. Test-only change; no runtime behavior changed in either package.

  • 26dfa07: Pre-existing test-suite fixes (unrelated to recent feature work): - `high-impact-hero`'s `illustrationHero` variant carried two tags (`brand`, `illustration`) outside the canonical taxonomy declared in `v2-coverage.test.ts`. Fixed to `['editorial', 'playful']`. The `illustrationHero` slug itself is kept camelCase (not renamed to kebab-case) because it shipped in the published `0.5.0` release (2026-05-21) and is stored verbatim in consumer content as a `_variant` field value — renaming would silently break every stored document that already selected it. The kebab-case rule now carries a documented, slug-scoped exception with a deprecation trigger (next content-breaking major version for this package, when a stored-content migration pass is already budgeted). - `blocks-core`'s `test/top20-variants.test.ts` imported `@wabbit/tome-blocks-marketing-starter` and the other bundle packs directly, which a later refactor made unresolvable when it removed blocks-core's devDeps on those packs to break the blocks-core ↔ marketing-starter dependency cycle. Relocated the test to `@wabbit/tome-blocks` (the meta-package that already depends on every pack for exactly this purpose) rather than re-adding the removed devDeps and recreating the cycle. Test-only change; no runtime behavior changed in either package.
  • 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.
  • 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • aef2725: Monolith decompositions (behavior- and markup-preserving; public APIs unchanged; markup identity mechanically verified per file): forms' FieldRenderer 633→84 via a field-control registry + shared FieldChrome (consent/checkbox byte-identical branches merged) and TomeForm 656→451 via four extracted hooks (the ordering-critical resolver sync deliberately stays inline, documented); rpg's CharacterSheet 841→130 across panels + three editing hooks + persistence hook (the StrictMode XP-ledger charRef guard preserved verbatim); gallery's GalleryIndex 1032→431 (BlockThumb/BlockCard/Toolbar/useFilteredCatalog siblings, T2's debounce+memo preserved); webgl's WebglCanvasProvider 938→546 (useTransitionOrchestrator + useCanvasRenderer extracted; settle thresholds hoisted to named consts); admin's mergeAdminComponents 828→404 orchestrator + four helpers (all docblocks relocated, 717 tests unmodified) and Nav's config-reading now typed (6 of 8 `as any` casts eliminated); marketing-starter's PricingPlans extracts its GSAP toggle timeline hook + a memoized card. rpg additionally trusts the denormalized `xpTotal` on sheet load/save hot paths (full recompute stays at the XP-recording reconciliation point).
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-blocks-extras@0.11.0
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0
v0.9.5patch

Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.

  • Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.
  • Updated dependencies - @wabbit/tome-blocks-extras@0.9.5
v0.9.4patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-extras@0.9.4
v0.9.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2
v0.9.1patch

Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1

  • Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1
v0.9.0minor

c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.

  • c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0
v0.8.0minor

249b670: Batch 1 (2026-06-27 inserter/variant architecture): author the usage/intent layer on the free `high-impact-hero` — it is the canonical free workhorse hero and the migration target for the deprecated low/medium-impact heroes. Additive metadata only. Deferred to the live-run pass (coupled with the instance data-migration + a visual A/B): the slug rename `high-impact-hero` → `hero` (with a back-compat alias) and the `heroTextTheme` parity field.

  • 249b670: Batch 1 (2026-06-27 inserter/variant architecture): author the usage/intent layer on the free `high-impact-hero` — it is the canonical free workhorse hero and the migration target for the deprecated low/medium-impact heroes. Additive metadata only. Deferred to the live-run pass (coupled with the instance data-migration + a visual A/B): the slug rename `high-impact-hero` → `hero` (with a back-compat alias) and the `heroTextTheme` parity field.
  • 249b670: Batch 2 feature consolidation (2026-06-27 inserter/variant architecture) — collapse the scattered feature blocks into the single `featureHero`. - **Deprecated** (still registered + rendered for back-compat, removed from the offered `extras` bundle + client-safe gallery meta): `feature-masonry`, `feature-with-large-media`, `feature-with-three-steps`, `media-feature` → the marketing-starter `featureHero`. Together with Batch 1's `feature-hero-with-cards` + `feature-with-icon-grid`, the whole feature family now consolidates to `featureHero`, whose 6-layout `variant` select already covers them all. - **Authored usage/intent metadata** on `featureHero` (now the sole offered feature block). - **Deferred to the live-run reconciliation pass** (brand-preserving but a field-semantics swap + data migration): `featureHero`'s dual variant mechanism — make the 6 LAYOUTS the `_variant` (so the VariantPicker drives layout, not the unrelated 4-value style axis), move the style axis to a secondary field, and drop the inline `variant`. Instance migration (standalone feature blocks → `featureHero` + the right layout) is deferred to a later release. Tier note: the feature layouts consolidate onto the FREE `featureHero` (it already carried all 6 free); unlike heroes there is no distinct premium feature layout to gate, so there is no paid `feature-pro`. Ships in the linked family's 0.8.0 minor.
  • 249b670: Batch 4 — Marketing & Data (2026-06-27 inserter/variant architecture). These six blocks already ship as one-block-plus-4-variants (clean `_variant`, no scattered duplicates), so this batch is audit + usage-authoring, not consolidation. - **Authored usage/intent metadata** (Decision 4) on `cta`, `logo-slider`, `pricing`, `testimonial`, `faq`, `banner` — so assembling agents + the gallery/inserter can select, order, and configure them. Additive metadata only. - **Audit finding flagged for the live-run reconciliation pass:** `pricing` carries a slug-split (Payload block `slug: 'pricingPlans'` camel vs meta `slug: 'pricing'` kebab) AND a redundant inline `displayOptions.variant` ('full'|'compact') alongside the `_variant` axis. Documented in pricing/meta.ts; deferred (couples with the slug-split decision + a data migration). The other five are clean. Ships in the linked family's 0.8.0 minor.
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670] - @wabbit/tome-blocks-extras@0.8.0 - @wabbit/tome-blocks-core@0.8.0
v0.7.0minor

28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.

  • 28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.
  • 8958d41: Expose a client-safe `./meta` export on each block pack: payload-free block descriptor metadata (slug/name/description/category/tags/source + variants), separate from the payload-importing root barrel. This is the companion to the `./demo` export. Each block's `meta` literal is now extracted into a co-located payload-free `meta` module that the block config imports, and a pack-level `./meta` entry exposes the full descriptor list as `<pack>BlockMeta`. A consumer registering packs client-side (the wabbit `/blocks` gallery storefront, B6) can now read block metadata for gallery entries without importing the root barrel, which eagerly pulls each block's config (`payload` -> `richtext-lexical` -> `pino` -> `worker_threads`) into the browser bundle. Additive and behavior-preserving: `defineBlock` receives the same meta object (now imported rather than inline); the block registry, configs, demos, and existing exports are unchanged. The pack `BlockMeta` array is also re-exported from the root barrel for path-alias consumers.
  • Updated dependencies [28802fa]
  • Updated dependencies [66c611c]
  • Updated dependencies [8958d41] - @wabbit/tome-blocks-extras@0.7.0 - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2
v0.6.1patch

f37fa00: feat(demo): add getDemoProps dispatchers to agency-essentials, marketing-starter, and signal-theme Each pack now ships a `demo.ts` barrel with a `getDemoProps(blockSlug, variant, ctx?)` dispatcher and per-block demo functions. The auto-gallery route in tome-starter can replace the `noopDemoProps` stub for all three bundles, eliminating the warning cards that appeared for every block × variant. - agency-essentials: 10 blocks × 4 variants each (about, contact, team-roster, gallery, timeline, stat, stat-bar, split-view, media, form) - marketing-starter: 8 blocks × 4-5 variants each (high-impact-hero, feature-hero, cta, logo-slider, pricing, testimonial, faq, banner) - signal-theme: 33 blocks (all signal-\* slugs); blocks with multi-variant configs covered (accordion: stacked/single, callout: tactical/lore) - DemoContext interface, individual block-level functions, and getDemoProps all re-exported from each pack barrel - richText fields intentionally omitted — GalleryRichTextProvider supplies the Lexical state at gallery render time - Relationship fields (pricing, testimonial) emit sentinel strings; gallery degrades gracefully Fixes risk R1 from the gallery gap audit. - @wabbit/tome-blocks-core@0.5.9

  • f37fa00: feat(demo): add getDemoProps dispatchers to agency-essentials, marketing-starter, and signal-theme Each pack now ships a `demo.ts` barrel with a `getDemoProps(blockSlug, variant, ctx?)` dispatcher and per-block demo functions. The auto-gallery route in tome-starter can replace the `noopDemoProps` stub for all three bundles, eliminating the warning cards that appeared for every block × variant. - agency-essentials: 10 blocks × 4 variants each (about, contact, team-roster, gallery, timeline, stat, stat-bar, split-view, media, form) - marketing-starter: 8 blocks × 4-5 variants each (high-impact-hero, feature-hero, cta, logo-slider, pricing, testimonial, faq, banner) - signal-theme: 33 blocks (all signal-\* slugs); blocks with multi-variant configs covered (accordion: stacked/single, callout: tactical/lore) - DemoContext interface, individual block-level functions, and getDemoProps all re-exported from each pack barrel - richText fields intentionally omitted — GalleryRichTextProvider supplies the Lexical state at gallery render time - Relationship fields (pricing, testimonial) emit sentinel strings; gallery degrades gracefully Fixes risk R1 from the gallery gap audit. - @wabbit/tome-blocks-core@0.5.9
v0.5.9patch

8947ff1: Three additive packaging fixes surfaced by a consumer's registry-consumption migration (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible. - @wabbit/tome-blocks-core@0.5.9 - @wabbit/tome-blocks-extras@0.5.9

  • 8947ff1: Three additive packaging fixes surfaced by a consumer's registry-consumption migration (path-aliasing was masking these — the actual package contracts didn't cover them): - `@wabbit/tome-blocks-marketing-starter`: add `./blocks/*` subpath exports for the 8 block directories (`banner`, `cta`, `faq`, `feature-hero`, `high-impact-hero`, `logo-slider`, `pricing`, `testimonial`). Source already shipped these as directories with `index.ts`; the `exports` map only declared `.` and `./render`, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap. - `@wabbit/tome-core`: add `./auth/collections/Roles` (capital R) alongside the existing lowercase `./auth/collections/roles`. Both resolve to the same file (`./dist/auth/collections/Roles.{js,cjs,d.ts}`). The source file is `Roles.ts`; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API. - `@wabbit/tome-ui`: add `./tokens.css` alongside the existing `./tokens` (both point at `./dist/tokens.css`). Lets consumers write `import '@wabbit/tome-ui/tokens.css'` to match the CSS-file naming convention as well as the existing `import '@wabbit/tome-ui/tokens'`. All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible. - @wabbit/tome-blocks-core@0.5.9 - @wabbit/tome-blocks-extras@0.5.9
v0.5.7patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
  • @wabbit/tome-blocks-extras@0.5.7
v0.5.0minor

`high-impact-hero`: add an `illustrationHero` variant (`characterImage` upload + `characterPosition`) for character-led heroes.

  • `high-impact-hero`: add an `illustrationHero` variant (`characterImage` upload + `characterPosition`) for character-led heroes.
v0.4.2patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2
v0.4.1patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0
v0.4.0patch

Updated dependencies [b76f684]

  • Updated dependencies [b76f684]
  • Updated dependencies [90a694d] - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-extras@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0 - @wabbit/tome-blocks-extras@0.3.0

Blocks Agency Essentials

v0.50.0
v0.50.0patch

2288bb9: `stat` wraps each description in a `display: contents` element carrying `data-stat-note`, a hook for themes. No visual change.

  • 2288bb9: `stat` wraps each description in a `display: contents` element carrying `data-stat-note`, a hook for themes. No visual change.
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.47.0minor

97907a6: `contact` can print a short caption under its link-out button. The `estimate` variant gains an optional `primaryAction.caption` ("Opens our scheduler in a new tab"), rendered as `data-contact-action-caption` in the actions row, and only when the button itself renders. Unset or blank, the markup is identical to before; the existing contact golden and a new blank-equals-unset test hold that. Schema: one new optional localized text column on the block (`primaryAction.caption`), so SQL sites need a migration; Mongo sites need none.

  • 97907a6: `contact` can print a short caption under its link-out button. The `estimate` variant gains an optional `primaryAction.caption` ("Opens our scheduler in a new tab"), rendered as `data-contact-action-caption` in the actions row, and only when the button itself renders. Unset or blank, the markup is identical to before; the existing contact golden and a new blank-equals-unset test hold that. Schema: one new optional localized text column on the block (`primaryAction.caption`), so SQL sites need a migration; Mongo sites need none.
v0.46.0minor

0272019: The gallery block gains an offset layout: one large lead image beside smaller ones. `gallery` gains an opt-in `offset` variant: one large lead image beside small ones with bold-lead captions (hooks `data-gallery-offset`, `data-gallery-lead`, `data-gallery-small`, `data-gallery-caption`). The layout-only admin fields hide for it. Every other variant renders exactly as before.

  • 0272019: The gallery block gains an offset layout: one large lead image beside smaller ones. `gallery` gains an opt-in `offset` variant: one large lead image beside small ones with bold-lead captions (hooks `data-gallery-offset`, `data-gallery-lead`, `data-gallery-small`, `data-gallery-caption`). The layout-only admin fields hide for it. Every other variant renders exactly as before.
  • 5e35bca: Fixes the gallery image height default, which was not one of its own options. Fix: the `gallery` element's `imageHeight` select defaulted to `21rem`, which was not one of its options (`12rem` to `32rem` in `2rem` steps), so any gallery element saved without an explicit height failed Payload validation ("`21rem` is not a valid enum value for path `imageHeight`"). Present since the pack's extraction. `21rem` is now an option between `20rem` and `22rem`; the default stays `21rem`, which is also the renderer's own fallback height (`--_item-height: 21rem`), so stored blocks and new ones render exactly as before. A new schema test checks that every select and radio default in the pack is one of its options. Consumers: regenerate Payload types (the `imageHeight` union gains `'21rem'`); with a SQL adapter the enum gains one value.
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.43.0minor

6b5c35c: `stat` and `stat-bar` can now cite the record behind each figure, and `stat` gains a `sourced` ledger variant. Each stat row takes an optional `source` group (`statSourceGroup()`: `serial`, `label`, `href`). A stat with a source prints `p[data-stat-source]` with `span[data-stat-source-serial]` ("No. 2026-02"), a middle dot and the label as a link: under the description on `stat`, under the row on `stat-bar`. Two block-level fields on both blocks: `requireSource` (off by default) drops any stat with no source label or link and hides the block when none is left, so a figure never shows without the record it cites; `sourcePrefix` (default "No.") is the word before each number. A stat without a source renders as before. The new `sourced` variant of `stat` is a ruled strip for such figures: a label cell (`data-stat-label-cell`) holding the headline and, on a demo, `sampleLabel` as `span[data-stat-sample][data-compliance-label]`, then `data-stat-cells` of `data-stat-cell`, each led by its source line, then `p[data-stat-value]` and `p[data-stat-caption]`. It prints a stat's suffix only when set; the field still defaults to "%", so clear it. `sampleLabel` shows in the admin for `sourced` only. Additive change to existing markup: every `stat` root now carries `data-block-variant` beside `data-variant` (the public variant hook), in all variants. The block's stored-render goldens were cut from the pre-change renderer and differ only by that attribute. `stat-bar` has no change to its markup. The block catalog lists the new variant.

  • 6b5c35c: `stat` and `stat-bar` can now cite the record behind each figure, and `stat` gains a `sourced` ledger variant. Each stat row takes an optional `source` group (`statSourceGroup()`: `serial`, `label`, `href`). A stat with a source prints `p[data-stat-source]` with `span[data-stat-source-serial]` ("No. 2026-02"), a middle dot and the label as a link: under the description on `stat`, under the row on `stat-bar`. Two block-level fields on both blocks: `requireSource` (off by default) drops any stat with no source label or link and hides the block when none is left, so a figure never shows without the record it cites; `sourcePrefix` (default "No.") is the word before each number. A stat without a source renders as before. The new `sourced` variant of `stat` is a ruled strip for such figures: a label cell (`data-stat-label-cell`) holding the headline and, on a demo, `sampleLabel` as `span[data-stat-sample][data-compliance-label]`, then `data-stat-cells` of `data-stat-cell`, each led by its source line, then `p[data-stat-value]` and `p[data-stat-caption]`. It prints a stat's suffix only when set; the field still defaults to "%", so clear it. `sampleLabel` shows in the admin for `sourced` only. Additive change to existing markup: every `stat` root now carries `data-block-variant` beside `data-variant` (the public variant hook), in all variants. The block's stored-render goldens were cut from the pre-change renderer and differ only by that attribute. `stat-bar` has no change to its markup. The block catalog lists the new variant.
v0.42.1patch

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

  • 50b7859: Fixes four single-byte text-encoding errors in the package's source and README, so admin field descriptions show "Ages 5–7" and "We’ll reply by 10am tomorrow" instead of replacement characters.
  • a4d1e12: `team-roster`'s `people` variant gains two opt-in layout options: `photoLabelPlacement: 'below'` and `statement.position: 'last'`. `photoLabelPlacement: 'below'` renders each photo label (still `data-compliance-label`) as the sibling directly after the portrait instead of inside it, for themes that keep type off photographs. `statement.position: 'last'` moves the statement tile after the people in the DOM, so the tab order matches a design that shows it last; empty-cell positions count the grid in that order. Both default to the current behavior and leave the rendered markup byte-identical when unset.
v0.41.0minor

a7603ae: `team-roster` `people` can put a button after the statement tile with the new `statement.link`. `statement.link` (`label`, `href`) shows only with both halves and is off by default, so stored documents without it render byte-identical. The button carries `data-person-statement-link`. `contact` `estimate` gains `signOff.order: 'name-first'` (default `line-first`): the name, then the line, with `data-contact-signoff-order="name-first"` on the sign-off. Unset or `line-first` renders byte-identical.

  • a7603ae: `team-roster` `people` can put a button after the statement tile with the new `statement.link`. `statement.link` (`label`, `href`) shows only with both halves and is off by default, so stored documents without it render byte-identical. The button carries `data-person-statement-link`. `contact` `estimate` gains `signOff.order: 'name-first'` (default `line-first`): the name, then the line, with `data-contact-signoff-order="name-first"` on the sign-off. Unset or `line-first` renders byte-identical.
v0.39.0minor

a3ea48b: Two opt-in additions for a business that shows its coaches and answers by the hour. `team-roster` `people` gains `people[].tag` (a short tag beside the name, `data-person-tag`), `people[].credentialTags[]` (chips in a row under the fact, `data-person-credentials` and `data-person-credential-tag`) and a block-level `credentialNote` (set after each person's chips, `data-person-credential-note`). All three are optional, default-free and shown only for the `people` variant; empty values render nothing. `contact` `estimate` gains `promiseOpen` and `promiseClosed`, each a `{ text }` group. With either set, the promise sentence renders as three copies marked `data-when-neutral` (from `promise.text`), `data-when-open` and `data-when-closed`, and the block's stylesheet chooses by `<html data-tome-open="true|false">`, the attribute chrome writes from the site's hours. With the attribute absent the neutral copy shows, so a site without hours never reads "closed". A side left empty falls back to the neutral text. Stored documents that set none of the new fields render byte-identical markup (pinned by golden tests). Purely additive: no peer is added or tightened, no field has a default, and nothing changes for a site that sets none of them.

  • a3ea48b: Two opt-in additions for a business that shows its coaches and answers by the hour. `team-roster` `people` gains `people[].tag` (a short tag beside the name, `data-person-tag`), `people[].credentialTags[]` (chips in a row under the fact, `data-person-credentials` and `data-person-credential-tag`) and a block-level `credentialNote` (set after each person's chips, `data-person-credential-note`). All three are optional, default-free and shown only for the `people` variant; empty values render nothing. `contact` `estimate` gains `promiseOpen` and `promiseClosed`, each a `{ text }` group. With either set, the promise sentence renders as three copies marked `data-when-neutral` (from `promise.text`), `data-when-open` and `data-when-closed`, and the block's stylesheet chooses by `<html data-tome-open="true|false">`, the attribute chrome writes from the site's hours. With the attribute absent the neutral copy shows, so a site without hours never reads "closed". A side left empty falls back to the neutral text. Stored documents that set none of the new fields render byte-identical markup (pinned by golden tests). Purely additive: no peer is added or tightened, no field has a default, and nothing changes for a site that sets none of them.
v0.38.0minor

f75fb49: The `form` block gains a `signup-band` variant, and `split-view` gains part hooks. `signup-band` is a dark closing band: `eyebrow`, `heading` (an h2), `body`, the form slot, a `promise` line and a `secondaryLink` (label and link). The new fields show only for this variant, carry no default, and every part renders only when set. The root carries `data-block-variant="signup-band"` and `data-band-tone="solid-dark"`; the parts carry `data-band-meta`, `data-band-title`, `data-band-body`, `data-band-form` (on the form slot, which keeps `data-form-slot` and `data-form-id`), `data-band-promise` and `data-band-link`. A form with one field lays its field and button on one row. Existing variants render exactly as before. `split-view` marks a text column's content `[data-sv-text]` and a media column's figure `[data-sv-media]`, next to the existing `[data-sv-ratio]`, so a theme can address the two parts of a pair. `split-view` gains structure hooks and a 7-5 ratio: `[data-sv-inner]` on the columns' grid, `[data-sv-col="media|text"]` on each media or text column, and `[data-sv-media-side="start|end"]` on the root when the block has a media column (`start` when the media column comes first). The `asymmetryRatio` select gains `7-5` and its mirror `5-7`, with the matching `7fr 5fr` / `5fr 7fr` layout. The `signup-band` variant marks its inner wrapper `[data-band-inner]` and the column holding the form slot and the promise `[data-band-action]`. Stored renders are unchanged apart from the new attributes.

  • f75fb49: The `form` block gains a `signup-band` variant, and `split-view` gains part hooks. `signup-band` is a dark closing band: `eyebrow`, `heading` (an h2), `body`, the form slot, a `promise` line and a `secondaryLink` (label and link). The new fields show only for this variant, carry no default, and every part renders only when set. The root carries `data-block-variant="signup-band"` and `data-band-tone="solid-dark"`; the parts carry `data-band-meta`, `data-band-title`, `data-band-body`, `data-band-form` (on the form slot, which keeps `data-form-slot` and `data-form-id`), `data-band-promise` and `data-band-link`. A form with one field lays its field and button on one row. Existing variants render exactly as before. `split-view` marks a text column's content `[data-sv-text]` and a media column's figure `[data-sv-media]`, next to the existing `[data-sv-ratio]`, so a theme can address the two parts of a pair. `split-view` gains structure hooks and a 7-5 ratio: `[data-sv-inner]` on the columns' grid, `[data-sv-col="media|text"]` on each media or text column, and `[data-sv-media-side="start|end"]` on the root when the block has a media column (`start` when the media column comes first). The `asymmetryRatio` select gains `7-5` and its mirror `5-7`, with the matching `7fr 5fr` / `5fr 7fr` layout. The `signup-band` variant marks its inner wrapper `[data-band-inner]` and the column holding the form slot and the promise `[data-band-action]`. Stored renders are unchanged apart from the new attributes.
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.37.0minor

5ff7817: The `contact` `estimate` variant can close a page beside a face: a portrait, a motif, a link-out button and a sign-off. Four optional fields, shown only for `estimate` and without defaults, so existing documents render the same markup as before: - `media` (`image`, `alt`, `label`): a portrait beside the note. Alt text is required once an image is set; the label renders only with the image. - `motif` (`image`, `alt`): a small decorative image, alt required with the image. - `primaryAction` (`label`, `href`, `newTab`): a link-out button rendered through `TomeLink`, so a new-tab link gets `data-new-tab` and the hidden "(opens in a new tab)" hint. It is separate from the tel-only `callAction`. - `signOff` (`line`, `name`): a closing line and a name at the end of the note, above the form. The images relate to a new `mediaCollection` option (default `'media'`). New theme hooks: `data-contact-action`, `data-contact-media` with `data-compliance-label` for its label, `data-contact-motif`, `data-contact-signoff`, `data-contact-signoff-line`, `data-contact-signoff-name`. The form area is unchanged: it takes a form you host through `formSlot` or the form builder's `cardForm`.

  • 5ff7817: The `contact` `estimate` variant can close a page beside a face: a portrait, a motif, a link-out button and a sign-off. Four optional fields, shown only for `estimate` and without defaults, so existing documents render the same markup as before: - `media` (`image`, `alt`, `label`): a portrait beside the note. Alt text is required once an image is set; the label renders only with the image. - `motif` (`image`, `alt`): a small decorative image, alt required with the image. - `primaryAction` (`label`, `href`, `newTab`): a link-out button rendered through `TomeLink`, so a new-tab link gets `data-new-tab` and the hidden "(opens in a new tab)" hint. It is separate from the tel-only `callAction`. - `signOff` (`line`, `name`): a closing line and a name at the end of the note, above the form. The images relate to a new `mediaCollection` option (default `'media'`). New theme hooks: `data-contact-action`, `data-contact-media` with `data-compliance-label` for its label, `data-contact-motif`, `data-contact-signoff`, `data-contact-signoff-line`, `data-contact-signoff-name`. The form area is unchanged: it takes a form you host through `formSlot` or the form builder's `cardForm`.
v0.36.3patch

1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.

  • 1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.
  • 47d30de: The form block takes your site's form as `formSlot`, the same contract as the contact block, so it no longer has to be filled from outside. The form block rendered only an empty slot for a site integration to fill, with no prop to hand it a form; every preview of it was an empty box. It now takes `formSlot` (rendered inside the slot) and `formSlotId` (written to `data-form-id`), and the slot always carries `data-form-slot`. An unpopulated `form` relationship (a bare id) now marks the slot as well. `FormBlockProps` is exported from `./render`.
v0.36.0minor

b2de6ef: `enhanceReserveBands` now accepts an opening-hours option, so a reservation band's sample time chips and time select respect the venue's hours. Call `enhanceReserveBands(document, { hours, listWindows, schedule?, lastSeatingMinutes?, lastPickupMinutes?, maxSlots?, notes? })` with a `@wabbit/tome-local` `OpenStateSource` and its `listWindows` (passed in, so this package takes no new dependency). Chips and select options outside the chosen date's hours, or already past today, hide; a note says when the date is closed or no sample time is left; a date still on today with no time left moves to the next open date. The existing `enhanceReserveBands(root, now)` call is unchanged. The contact `reserve` group gains two optional localized fields, `closedNote` and `noTimesNote`, for the notes' wording; unset, a neutral default shows.

  • b2de6ef: `enhanceReserveBands` now accepts an opening-hours option, so a reservation band's sample time chips and time select respect the venue's hours. Call `enhanceReserveBands(document, { hours, listWindows, schedule?, lastSeatingMinutes?, lastPickupMinutes?, maxSlots?, notes? })` with a `@wabbit/tome-local` `OpenStateSource` and its `listWindows` (passed in, so this package takes no new dependency). Chips and select options outside the chosen date's hours, or already past today, hide; a note says when the date is closed or no sample time is left; a date still on today with no time left moves to the next open date. The existing `enhanceReserveBands(root, now)` call is unchanged. The contact `reserve` group gains two optional localized fields, `closedNote` and `noTimesNote`, for the notes' wording; unset, a neutral default shows.
  • 2e9f1ab: Phone links in the contact, high-impact-hero and local-logistics code now use the shared core `tel:` helper, so a site can qualify national numbers to E.164. The exported `toTelHref` helpers (and `resolveContactValueLink`, `resolveInlineCall`) accept an optional `{ defaultCallingCode }`. The high-impact-hero reads `defaultCallingCode` from the render context passed to `RenderBlocks({ context })`; without it, hrefs stay digits-only as before. Requires `@wabbit/tome-core` 1.22.0 or later as a peer.
  • 8e25fd4: The `contact` renderer reads `defaultCallingCode` from the host's render context (`RenderBlocks({ context })`) for its contact rows, the estimate and reserve call buttons, and reserve call actions, so `tel:` links are E.164 when the site sets a default. Unset, links are unchanged.
v0.35.0patch

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

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

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

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

2d4e1d4: contact `estimate`: the form slot is marked whatever fills it, and the band's padding no longer doubles. - The slot now always carries `data-contact-form-slot`. Its `data-form-id` came from `cardForm` only, so a form passed as `formSlot` (a tome-forms form, for instance) left it unmarked and theme rules keyed on `[data-form-id]` never reached it. New optional prop `formSlotId`: the hosted form's id, written to `data-form-id` (it wins over `cardForm` when a `formSlot` is passed). A site that wrapped its hosted form in its own `data-form-id` element can drop the wrapper. - The block padding (`--tome-section-py`) moves from `.estInner` to the section, the element that is the band. On the inner element it stacked with any padding a theme gave the band, so the Counsel intake ran double space above and below. Without a theme band rule the spacing is unchanged.

  • 2d4e1d4: contact `estimate`: the form slot is marked whatever fills it, and the band's padding no longer doubles. - The slot now always carries `data-contact-form-slot`. Its `data-form-id` came from `cardForm` only, so a form passed as `formSlot` (a tome-forms form, for instance) left it unmarked and theme rules keyed on `[data-form-id]` never reached it. New optional prop `formSlotId`: the hosted form's id, written to `data-form-id` (it wins over `cardForm` when a `formSlot` is passed). A site that wrapped its hosted form in its own `data-form-id` element can drop the wrapper. - The block padding (`--tome-section-py`) moves from `.estInner` to the section, the element that is the band. On the inner element it stacked with any padding a theme gave the band, so the Counsel intake ran double space above and below. Without a theme band rule the spacing is unchanged.
  • 80cf130: `team-roster` `people` sits on the page grid, and its columns are a token. - The section kept its children on `1 / -1`, so the header and the portrait grid ran edge to edge with no page gutter. The section still paints full-bleed, but it now subgrids the page grid and places the header and grid on the content lane (`content-start / content-end`), like the pack's other blocks. - New token `--tome-roster-people-cols`: the grid's column count. Defaults are 2 on phones, 3 from 640px and **4 from 1024px** (it was 3). The statement tile and the empty-cell rules are unchanged: across the row on phones, across two cells from 640px, one cell from 1024px, where the empty cells appear. The demo's `emptyCells` `[5, 8]` now open the start and end of row two. - The header group carries hooks: `data-block-header` on the group, `data-block-eyebrow` (section label), `data-block-heading` (`h2`) and `data-block-subtext`. The member variants are unchanged.
  • 3e4f0a7: Narrow-screen rules that never fired now fire. These blocks wrote their phone layout as an unnamed `@container` query, but nothing around them declares a size container (not tome-ui, the blocks core or the starter), so the rules never matched: the `high-impact-hero` `split` and default layouts never stacked on phones. Declaring the container on the block root would not work either, because size containment turns a `subgrid` root into a plain grid and drops the page-grid lines its children sit on. The rules are viewport queries now, at the same breakpoints; the blocks are full-bleed, so the viewport is their width. - `@wabbit/tome-blocks-marketing-starter`: `high-impact-hero` (two columns stack to one, large headline sizes step down, at 768px and below). - `@wabbit/tome-blocks-extras`: `BlogHero`, `ChapterHero`, `CustomHero`, `ImageHero`, `LowImpactHero`, `MediumImpactHero`, `PageHero`, `StudyHero`, `TypographyHero` (768px) and `SectionHeader` (640px). - `@wabbit/tome-blocks-content-writer`: `PostHero` (768px). - `@wabbit/tome-blocks-agency-essentials`: `SplitView` stacks at 768px. Above those widths nothing changes.
v0.33.0minor

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

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

f58f657: Counsel v1 agency pieces: a `people` variant for `team-roster` and an `aside` for the `contact` `estimate` variant. **`team-roster` `people`:** a statement tile, then tall portrait cards. The variant has its own optional fields, shown only when it is picked: `people[]` (`name` required, `role`, `credential`, `fact`, `photo`, `photoLabel`), `statement` (`text`, `label`) and `emptyCells` (1-based positions of deliberate empty cells, counted across every cell including the statement). Each card shows the name, role, a credential line and one personal fact in italic. `photoLabel` renders on the portrait as `data-compliance-label`, only when there is a photo. Empty cells are `aria-hidden` and appear from 1024px, where the grid has three columns. Hooks: `data-block-variant="people"`, `data-roster-statement`, `data-roster-person`, `data-person-name`, `data-person-role`, `data-person-credential`, `data-person-fact`, `data-roster-empty`. `members[]` and `epigraph` are now hidden in the admin for `people` only. The other four variants render exactly as before. **`contact` `estimate`:** an optional `aside` group (`label`, plus a `body` of a few short paragraphs in limited rich text, bold and italic only), for a note such as a confidentiality notice. It renders last in the intro column as `<aside data-contact-aside>`, named by its label. With no label and no body text, the output is unchanged.

  • f58f657: Counsel v1 agency pieces: a `people` variant for `team-roster` and an `aside` for the `contact` `estimate` variant. **`team-roster` `people`:** a statement tile, then tall portrait cards. The variant has its own optional fields, shown only when it is picked: `people[]` (`name` required, `role`, `credential`, `fact`, `photo`, `photoLabel`), `statement` (`text`, `label`) and `emptyCells` (1-based positions of deliberate empty cells, counted across every cell including the statement). Each card shows the name, role, a credential line and one personal fact in italic. `photoLabel` renders on the portrait as `data-compliance-label`, only when there is a photo. Empty cells are `aria-hidden` and appear from 1024px, where the grid has three columns. Hooks: `data-block-variant="people"`, `data-roster-statement`, `data-roster-person`, `data-person-name`, `data-person-role`, `data-person-credential`, `data-person-fact`, `data-roster-empty`. `members[]` and `epigraph` are now hidden in the admin for `people` only. The other four variants render exactly as before. **`contact` `estimate`:** an optional `aside` group (`label`, plus a `body` of a few short paragraphs in limited rich text, bold and italic only), for a note such as a confidentiality notice. It renders last in the intro column as `<aside data-contact-aside>`, named by its label. With no label and no body text, the output is unchanged.
v0.31.0minor

5f2662c: Groundwork v1.1 block fields for the quick-ask hero and the estimate contact band. **`high-impact-hero` `quick-ask`:** `proofLine` gains an optional `proofLead` text field, rendered as a `<strong data-hero-proof-lead>` before the proof text (for example "Next opening:"). Fixed: at phone widths the status dot could wrap onto its own line away from its text. The proof line is now inline flow, and the dot shares a no-wrap run (`data-hero-proof-start`) with its first phrase: the lead, or the first word of the text. The rest of the line wraps normally. With no lead and no dot, the markup is unchanged. **`contact` `estimate`:** two optional text fields. `eyebrow` is a small meta-voice kicker above the heading (`data-contact-eyebrow`). `formNote` is a short meta line beside the form card heading (`data-contact-form-note`). When it is set, the heading and note share a wrapping head row (`data-contact-card-head`). When both fields are empty, the output is unchanged.

  • 5f2662c: Groundwork v1.1 block fields for the quick-ask hero and the estimate contact band. **`high-impact-hero` `quick-ask`:** `proofLine` gains an optional `proofLead` text field, rendered as a `<strong data-hero-proof-lead>` before the proof text (for example "Next opening:"). Fixed: at phone widths the status dot could wrap onto its own line away from its text. The proof line is now inline flow, and the dot shares a no-wrap run (`data-hero-proof-start`) with its first phrase: the lead, or the first word of the text. The rest of the line wraps normally. With no lead and no dot, the markup is unchanged. **`contact` `estimate`:** two optional text fields. `eyebrow` is a small meta-voice kicker above the heading (`data-contact-eyebrow`). `formNote` is a short meta line beside the form card heading (`data-contact-form-note`). When it is set, the heading and note share a wrapping head row (`data-contact-card-head`). When both fields are empty, the output is unchanged.
v0.30.0minor

726b93a: The `contact` block's `estimate` variant takes an optional `cardHeading`, rendered at the top of the form card with a `[data-contact-card-heading]` hook. The field is shown only for `estimate` and renders nothing when empty, so existing estimate blocks are unchanged.

  • 726b93a: The `contact` block's `estimate` variant takes an optional `cardHeading`, rendered at the top of the form card with a `[data-contact-card-heading]` hook. The field is shown only for `estimate` and renders nothing when empty, so existing estimate blocks are unchanged.
v0.29.0minor

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

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

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.28.0patch

483e0a1: The timeline preview no longer names a specific platform launch. The last timeline milestone reads as a generic flagship service launch. Block fields and variants are unchanged.

  • 483e0a1: The timeline preview no longer names a specific platform launch. The last timeline milestone reads as a generic flagship service launch. Block fields and variants are unchanged.
  • 58655f4: Blocks now carry the neutral Tome source tag instead of the legacy source tag, and variant descriptions use plain layout names; stored content is unchanged.
v0.27.0patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.24.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 10 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 10 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.18.3patch

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

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

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 04309f5: Fix a hydration mismatch in `Testimonial` and adopt the shared UTC-pinned date formatter across five renderers. `@wabbit/tome-blocks-marketing-starter`'s `Testimonial` is a `'use client'` component and rendered its publication date with a bare `new Date(iso).toLocaleDateString()`. That resolves against the RUNTIME locale and the RUNTIME time zone, so the server produced one string and the browser produced another and React reported a hydration mismatch on the block. The 2026-07-11 audit flagged it; it was still there on 2026-09-01. The same bare call sat in four server renderers — `blocks-editorial-pack`'s `MetricStrip` and `StatusBoard`, `blocks-agency-essentials`' `TeamRoster` (twice) and `Timeline`. No hydration mismatch there, but the rendered output changed with the deploy host's locale and offset, which is its own kind of wrong. All six call sites now use `formatDisplayDate` from `@wabbit/tome-blocks-core/utilities/formatDisplayDate` with an explicit `locale: 'en-US'`, a numeric `M/D/YYYY` `dateStyle` and `timeZone: 'UTC'`. The numeric shape is deliberate: it is byte-identical to what a US-locale runtime already produced, so this fixes the determinism without silently restyling anyone's dates. Every pack already declared `@wabbit/tome-blocks-core`, so no dependency changes. Shipped with its forcing function: a `no-restricted-syntax` rule in `eslint.config.mjs` warns on bare `toLocaleDateString`/`toLocaleString`/`toLocaleTimeString` in every `packages/*/src/**/*.tsx` and every block pack's `render/` directory, and points at `formatDisplayDate` and at `blocks-gallery`'s `ADDED_AT_FORMATTER` as the two accepted shapes.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0 - @wabbit/tome-blocks-extras@0.16.0
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
  • Updated dependencies [54ff357] - @wabbit/tome-blocks-extras@0.15.12
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<color> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
  • Updated dependencies [1bebcdc]
  • Updated dependencies [48773ac] - @wabbit/tome-blocks-extras@0.15.11
v0.15.10patch

Alignment republish: these two artifacts were the last on the registry published before the workspace:^ policy, carrying exact @wabbit dependency pins (blocks-core 0.15.0, blocks-extras 0.15.0) that force nested duplicate copies — and split blocks-core's renderer/link registries — in any consumer whose tree moves past 0.15.0. No source changes; the republish ships range deps.

  • Alignment republish: these two artifacts were the last on the registry published before the workspace:^ policy, carrying exact @wabbit dependency pins (blocks-core 0.15.0, blocks-extras 0.15.0) that force nested duplicate copies — and split blocks-core's renderer/link registries — in any consumer whose tree moves past 0.15.0. No source changes; the republish ships range deps.
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-extras@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0
v0.13.0patch

Updated dependencies [f4d55c9]

  • Updated dependencies [f4d55c9]
  • Updated dependencies [eb403d4] - @wabbit/tome-blocks-core@0.13.0 - @wabbit/tome-blocks-extras@0.13.0
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2
v0.11.0patch

6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.

  • 6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.
  • 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).
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-blocks-extras@0.11.0
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0
v0.9.5patch

Updated dependencies - @wabbit/tome-blocks-extras@0.9.5

  • Updated dependencies - @wabbit/tome-blocks-extras@0.9.5
v0.9.4patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-extras@0.9.4
v0.9.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2
v0.9.1patch

Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1

  • Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1
v0.9.0minor

c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.

  • c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0
v0.8.0patch

Updated dependencies [249b670]

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

Updated dependencies [28802fa]

  • Updated dependencies [28802fa]
  • Updated dependencies [66c611c]
  • Updated dependencies [8958d41] - @wabbit/tome-blocks-extras@0.7.0 - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2
v0.6.1patch

f37fa00: feat(demo): add getDemoProps dispatchers to agency-essentials, marketing-starter, and signal-theme Each pack now ships a `demo.ts` barrel with a `getDemoProps(blockSlug, variant, ctx?)` dispatcher and per-block demo functions. The auto-gallery route in tome-starter can replace the `noopDemoProps` stub for all three bundles, eliminating the warning cards that appeared for every block × variant. - agency-essentials: 10 blocks × 4 variants each (about, contact, team-roster, gallery, timeline, stat, stat-bar, split-view, media, form) - marketing-starter: 8 blocks × 4-5 variants each (high-impact-hero, feature-hero, cta, logo-slider, pricing, testimonial, faq, banner) - signal-theme: 33 blocks (all signal-\* slugs); blocks with multi-variant configs covered (accordion: stacked/single, callout: tactical/lore) - DemoContext interface, individual block-level functions, and getDemoProps all re-exported from each pack barrel - richText fields intentionally omitted — GalleryRichTextProvider supplies the Lexical state at gallery render time - Relationship fields (pricing, testimonial) emit sentinel strings; gallery degrades gracefully Fixes risk R1 from the gallery gap audit. - @wabbit/tome-blocks-core@0.5.9

  • f37fa00: feat(demo): add getDemoProps dispatchers to agency-essentials, marketing-starter, and signal-theme Each pack now ships a `demo.ts` barrel with a `getDemoProps(blockSlug, variant, ctx?)` dispatcher and per-block demo functions. The auto-gallery route in tome-starter can replace the `noopDemoProps` stub for all three bundles, eliminating the warning cards that appeared for every block × variant. - agency-essentials: 10 blocks × 4 variants each (about, contact, team-roster, gallery, timeline, stat, stat-bar, split-view, media, form) - marketing-starter: 8 blocks × 4-5 variants each (high-impact-hero, feature-hero, cta, logo-slider, pricing, testimonial, faq, banner) - signal-theme: 33 blocks (all signal-\* slugs); blocks with multi-variant configs covered (accordion: stacked/single, callout: tactical/lore) - DemoContext interface, individual block-level functions, and getDemoProps all re-exported from each pack barrel - richText fields intentionally omitted — GalleryRichTextProvider supplies the Lexical state at gallery render time - Relationship fields (pricing, testimonial) emit sentinel strings; gallery degrades gracefully Fixes risk R1 from the gallery gap audit. - @wabbit/tome-blocks-core@0.5.9
v0.5.9patch

@wabbit/tome-blocks-core@0.5.9

  • @wabbit/tome-blocks-core@0.5.9
  • @wabbit/tome-blocks-extras@0.5.9
v0.5.7patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
  • @wabbit/tome-blocks-extras@0.5.7
v0.5.0minor

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

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

Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2
v0.4.1patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0
v0.4.0patch

Updated dependencies [b76f684]

  • Updated dependencies [b76f684]
  • Updated dependencies [90a694d] - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-extras@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Catalog Pack

v0.49.4
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.44.0patch

024edf0: Internal Tome dependencies are published as compatible ranges, not exact versions, so installs share one copy of tome-core. Before, these packs pinned the exact tome-core version that was current when they shipped, so an install could end up with two copies of tome-core and split its shared registries. They now declare a compatible `^` range that npm can resolve to a single copy.

  • 024edf0: Internal Tome dependencies are published as compatible ranges, not exact versions, so installs share one copy of tome-core. Before, these packs pinned the exact tome-core version that was current when they shipped, so an install could end up with two copies of tome-core and split its shared registries. They now declare a compatible `^` range that npm can resolve to a single copy.
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.43.0patch

8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The product-gallery `frameRatio` was a select whose values (`4:5`, `3:4`, ...) contain a colon, which made Payload's GraphQL schema build throw and `/api/graphql` return 500 on any site using the block. It is now a text field with the same default (`4:5`) that accepts exactly `4:5`, `1:1`, `3:4`, `3:2` or empty; existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it.

  • 8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The product-gallery `frameRatio` was a select whose values (`4:5`, `3:4`, ...) contain a colon, which made Payload's GraphQL schema build throw and `/api/graphql` return 500 on any site using the block. It is now a text field with the same default (`4:5`) that accepts exactly `4:5`, `1:1`, `3:4`, `3:2` or empty; existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it.
v0.39.3
v0.39.1
v0.38.0minor

378f888: Catalog blocks now show live prices, images and stock, and add a shared product tile, a numbered contents list, an annotated product spotlight and a product gallery. - **New block `product-gallery`.** A product's frames at one steady ratio (4:5 by default), a thumbnail row with a cross-fade, and a swipe with dots on phones; the first frame loads first. Frames are authored, or come from the product's catalog media (the primary first, then by sort order) when you look it up by `sku` or `slug`. Hooks: `data-gallery`, `data-gallery-frames`, `data-gallery-frame`, `data-gallery-thumbs`, `data-gallery-thumb` (`aria-current`), `data-gallery-dots`. - **New variant `category-strip` `contents`.** Numbered rows ruled like a printed contents page: the number, the title, one line and a live piece count sit on one shared baseline, on the page grid's lanes from 1024px, under the shared opener. New fields `kicker`, `moreLink`, `showCount`, `countLabel {one, other}` and `categories[].line`. The order number and count are live when a commerce layer has registered a listing source, and absent otherwise. Hooks: `data-contents-row`, `-no`, `-name`, `-line`, `-count`. - **New variant `featured-product` `annotated`.** The shared opener, an in-use plate, the product's line and margin notes. New fields `media` (the in-use frame, 3:2), `mediaAlt`, `notes[] {label, text}` and `moreLink`; the plate falls back to the live second image, then to the image. The notes stand in the left margin beside the plate and become a two-column list on phones. Hooks: `data-annotated-media`, `data-margin-notes`, `data-margin-note`, `data-note-label`, `data-note-text`. - **Shared product tile and shared opener.** `ProductTile` (from `./render`) is a plate (image, flags, add) over an info block, carrying `data-product-tile`, `data-tile-plate`, `data-tile-image`, `data-tile-flags`, `data-edition-tag`, `data-tile-badge`, `data-tile-eyebrow`, `data-tile-name`, `data-tile-desc`, `data-price`, `data-price-now`, `data-price-was`, `data-tile-soon` and `data-availability`. The add goes through blocks-core's `resolveAddToBag`, so it is a plain link to the product until a commerce layer registers the real control. `Opener` emits the `data-opener-*` masthead hooks. The `ProductTileData` contract and `pickTileBadge()` are exported. - **Live commerce data through registered sources.** `registerCatalogListingSource()` and `registerCatalogAvailabilitySource()` (from `./server`) let a commerce layer supply prices, images, stock and category counts without this pack importing it. With a listing source, `product-card` and `featured-product` overlay the lowest active price ("from" when variant prices differ), a real compare-at price as the struck previous price, the product image and a stock line; the price pair is overlaid as a unit, so a stale authored strike never sits beside a live price. With an availability source, `inventory-badge` follows the SKU's real availability (`resolveInventoryBadgeData()`), and a stale authored count is cleared. `listingQueryFromSearchParams()` and `listingQueryToSearchParams()` give the server render and the filter island one URL state. `createHydratedRenderers()` now also returns `inventory-badge` and `product-gallery`. - **`product-card` and `featured-product` gain an `image` field** (and `imageAlt`). A bare upload id draws nothing, so a document that only carries a stale `image` key renders as before. - **Part hooks on the existing cards.** `product-card` emits `data-tile-name`, `data-tile-desc`, `data-price`, `data-price-now` and `data-price-was`, and `featured-product` emits `data-price`, `data-price-now` and `data-price-was`, so a theme can style them; the rest of every existing variant's markup is byte-identical (the snapshot tests hold a baseline and an explicit allowance list). With no source registered, every block renders its authored data exactly as before. - **Theme hooks, additive.** `ProductTile`'s info block carries `data-tile-info`; the `product-grid` `shelf` inner wrapper carries `data-shelf-inner`; `featured-product` `annotated` carries `data-annotated-body` (the body), `data-annotated-desc` (the description) and `data-annotated-action` (the action link); and each `product-gallery` frame carries `data-frame-role="primary"` (the first frame, which is the product's primary image when the frames come from the catalog) or `"detail"`, so a theme can treat a close-up differently from the packshot. Stored renders differ only by these attributes. - **Empty filter options meet AA.** An option with no results was faded with `opacity: 0.45`, which fails contrast. It now takes the muted-ink token at full opacity; `data-facet-empty` (and the disabled checkbox) still mark it, and no opacity is applied. - **Checkpoint B fixes, all opt-in or additive.** The shelf grid now passes `restock` to every tile, so a sold-out tile (or a priced tile that cannot be bought) renders the restock control instead of nothing. The add and the restock control sit in a `data-tile-add` wrapper that the pack places bottom right of the plate, so the control's own styling can no longer move it over the edition tag. The sale badge reads "Save $22.00" (`compareAtCents - priceCents`, formatted on the server); the new `product-grid` field `saveLabel` (and the tile's `saveLabel` prop) sets the wording with `{amount}`, and a tile with no real saving gets no badge. The "New" badge now needs `edition.isNew === true` from the listing source: `isCurrent` alone only says the piece is in the current batch, so by default no tile is marked New (new optional `isNew` on the tile's `edition`). Each facet option carries `data-facet-box`, a drawn checkbox that follows the input's checked, disabled and focus states; the baseline hides it, a theme that hides the native input turns it on. The sort control's featured option is labeled "Featured" instead of "Shelf order"; it orders by the catalog's manual product order (see the storefront and catalog changesets), and "Newest" stays its own sort. - **`category-strip` `contents` counts.** A row shows its own count whenever it has one. The opener total (`showCount`) uses the listing source's own total when it gives one, else the sum of the rows only if every row has a count. The live counts and total no longer depend on a `catalog-categories` name match: the resolver used to return early when none matched, which dropped every count. Checkpoint B round 2: - **Shop at 1024px and up.** The filter bar's main column (toolbar, results, more link) carries `data-filter-main`, so a theme can place it as one grid area. The Shelf theme's rule expected the three as direct children of the rail layout and never matched. - **Sold-out tiles on the occasion row.** A tile that shows its second image (`imageRole: 'second'`), or is given `compactRestock`, asks for the restock in `size: 'compact'`: one line, "Email me when it's back", instead of the form over the plate. The shop's primary-image tile keeps the small form. `compactRestock` is a new optional `ProductTile` prop. - **A count-only opener is a caption.** An opener with only a count (no label, title or link) carries `data-opener-bare`, so a theme can drop its rule and gap; the count still rides in the label when there is one. Polish: - **One restock rule for grid tiles.** `ProductTile` now asks for the compact restock chip ("Email me when it's back", `size: 'compact'`) on every grid tile, so the shop's sold-out tile matches the occasion row; the full form lives on the product page. It used to use the compact chip only on a tile showing its second image. `compactRestock: false` opts a tile back into the small form ("Tell me when it is back"). Stored sold-out shop tiles change from the small form to the chip, which is the one intended markup change; the occasion row is unchanged. Parity pass (2026-10-19): - **Occasion tabs hold one height.** The tab panels now share one grid cell (`data-grid-panels`), so the row takes the tallest panel's height and swapping tabs never moves the page below it. An inactive panel is `visibility: hidden`, `aria-hidden` and `inert` (out of the tab order and the accessibility tree) instead of the `hidden` attribute, and each panel carries `data-active`. Untabbed grids render as before. - **Guide tile kicker and links.** The `product-grid` `shelf` `guideTile` gains optional `kicker` and `links[]` (up to four `{label, href}`); both render only when set. The tile's box carries `data-guide-plate`, with `data-guide-kicker`, `data-guide-title`, `data-guide-links` and `data-guide-link`, so a theme can stand it on the shelf line at plate height. A guide tile with only `title`, `line` and `href` renders as before apart from the new hook attributes.

  • 378f888: Catalog blocks now show live prices, images and stock, and add a shared product tile, a numbered contents list, an annotated product spotlight and a product gallery. - **New block `product-gallery`.** A product's frames at one steady ratio (4:5 by default), a thumbnail row with a cross-fade, and a swipe with dots on phones; the first frame loads first. Frames are authored, or come from the product's catalog media (the primary first, then by sort order) when you look it up by `sku` or `slug`. Hooks: `data-gallery`, `data-gallery-frames`, `data-gallery-frame`, `data-gallery-thumbs`, `data-gallery-thumb` (`aria-current`), `data-gallery-dots`. - **New variant `category-strip` `contents`.** Numbered rows ruled like a printed contents page: the number, the title, one line and a live piece count sit on one shared baseline, on the page grid's lanes from 1024px, under the shared opener. New fields `kicker`, `moreLink`, `showCount`, `countLabel {one, other}` and `categories[].line`. The order number and count are live when a commerce layer has registered a listing source, and absent otherwise. Hooks: `data-contents-row`, `-no`, `-name`, `-line`, `-count`. - **New variant `featured-product` `annotated`.** The shared opener, an in-use plate, the product's line and margin notes. New fields `media` (the in-use frame, 3:2), `mediaAlt`, `notes[] {label, text}` and `moreLink`; the plate falls back to the live second image, then to the image. The notes stand in the left margin beside the plate and become a two-column list on phones. Hooks: `data-annotated-media`, `data-margin-notes`, `data-margin-note`, `data-note-label`, `data-note-text`. - **Shared product tile and shared opener.** `ProductTile` (from `./render`) is a plate (image, flags, add) over an info block, carrying `data-product-tile`, `data-tile-plate`, `data-tile-image`, `data-tile-flags`, `data-edition-tag`, `data-tile-badge`, `data-tile-eyebrow`, `data-tile-name`, `data-tile-desc`, `data-price`, `data-price-now`, `data-price-was`, `data-tile-soon` and `data-availability`. The add goes through blocks-core's `resolveAddToBag`, so it is a plain link to the product until a commerce layer registers the real control. `Opener` emits the `data-opener-*` masthead hooks. The `ProductTileData` contract and `pickTileBadge()` are exported. - **Live commerce data through registered sources.** `registerCatalogListingSource()` and `registerCatalogAvailabilitySource()` (from `./server`) let a commerce layer supply prices, images, stock and category counts without this pack importing it. With a listing source, `product-card` and `featured-product` overlay the lowest active price ("from" when variant prices differ), a real compare-at price as the struck previous price, the product image and a stock line; the price pair is overlaid as a unit, so a stale authored strike never sits beside a live price. With an availability source, `inventory-badge` follows the SKU's real availability (`resolveInventoryBadgeData()`), and a stale authored count is cleared. `listingQueryFromSearchParams()` and `listingQueryToSearchParams()` give the server render and the filter island one URL state. `createHydratedRenderers()` now also returns `inventory-badge` and `product-gallery`. - **`product-card` and `featured-product` gain an `image` field** (and `imageAlt`). A bare upload id draws nothing, so a document that only carries a stale `image` key renders as before. - **Part hooks on the existing cards.** `product-card` emits `data-tile-name`, `data-tile-desc`, `data-price`, `data-price-now` and `data-price-was`, and `featured-product` emits `data-price`, `data-price-now` and `data-price-was`, so a theme can style them; the rest of every existing variant's markup is byte-identical (the snapshot tests hold a baseline and an explicit allowance list). With no source registered, every block renders its authored data exactly as before. - **Theme hooks, additive.** `ProductTile`'s info block carries `data-tile-info`; the `product-grid` `shelf` inner wrapper carries `data-shelf-inner`; `featured-product` `annotated` carries `data-annotated-body` (the body), `data-annotated-desc` (the description) and `data-annotated-action` (the action link); and each `product-gallery` frame carries `data-frame-role="primary"` (the first frame, which is the product's primary image when the frames come from the catalog) or `"detail"`, so a theme can treat a close-up differently from the packshot. Stored renders differ only by these attributes. - **Empty filter options meet AA.** An option with no results was faded with `opacity: 0.45`, which fails contrast. It now takes the muted-ink token at full opacity; `data-facet-empty` (and the disabled checkbox) still mark it, and no opacity is applied. - **Checkpoint B fixes, all opt-in or additive.** The shelf grid now passes `restock` to every tile, so a sold-out tile (or a priced tile that cannot be bought) renders the restock control instead of nothing. The add and the restock control sit in a `data-tile-add` wrapper that the pack places bottom right of the plate, so the control's own styling can no longer move it over the edition tag. The sale badge reads "Save $22.00" (`compareAtCents - priceCents`, formatted on the server); the new `product-grid` field `saveLabel` (and the tile's `saveLabel` prop) sets the wording with `{amount}`, and a tile with no real saving gets no badge. The "New" badge now needs `edition.isNew === true` from the listing source: `isCurrent` alone only says the piece is in the current batch, so by default no tile is marked New (new optional `isNew` on the tile's `edition`). Each facet option carries `data-facet-box`, a drawn checkbox that follows the input's checked, disabled and focus states; the baseline hides it, a theme that hides the native input turns it on. The sort control's featured option is labeled "Featured" instead of "Shelf order"; it orders by the catalog's manual product order (see the storefront and catalog changesets), and "Newest" stays its own sort. - **`category-strip` `contents` counts.** A row shows its own count whenever it has one. The opener total (`showCount`) uses the listing source's own total when it gives one, else the sum of the rows only if every row has a count. The live counts and total no longer depend on a `catalog-categories` name match: the resolver used to return early when none matched, which dropped every count. Checkpoint B round 2: - **Shop at 1024px and up.** The filter bar's main column (toolbar, results, more link) carries `data-filter-main`, so a theme can place it as one grid area. The Shelf theme's rule expected the three as direct children of the rail layout and never matched. - **Sold-out tiles on the occasion row.** A tile that shows its second image (`imageRole: 'second'`), or is given `compactRestock`, asks for the restock in `size: 'compact'`: one line, "Email me when it's back", instead of the form over the plate. The shop's primary-image tile keeps the small form. `compactRestock` is a new optional `ProductTile` prop. - **A count-only opener is a caption.** An opener with only a count (no label, title or link) carries `data-opener-bare`, so a theme can drop its rule and gap; the count still rides in the label when there is one. Polish: - **One restock rule for grid tiles.** `ProductTile` now asks for the compact restock chip ("Email me when it's back", `size: 'compact'`) on every grid tile, so the shop's sold-out tile matches the occasion row; the full form lives on the product page. It used to use the compact chip only on a tile showing its second image. `compactRestock: false` opts a tile back into the small form ("Tell me when it is back"). Stored sold-out shop tiles change from the small form to the chip, which is the one intended markup change; the occasion row is unchanged. Parity pass (2026-10-19): - **Occasion tabs hold one height.** The tab panels now share one grid cell (`data-grid-panels`), so the row takes the tallest panel's height and swapping tabs never moves the page below it. An inactive panel is `visibility: hidden`, `aria-hidden` and `inert` (out of the tab order and the accessibility tree) instead of the `hidden` attribute, and each panel carries `data-active`. Untabbed grids render as before. - **Guide tile kicker and links.** The `product-grid` `shelf` `guideTile` gains optional `kicker` and `links[]` (up to four `{label, href}`); both render only when set. The tile's box carries `data-guide-plate`, with `data-guide-kicker`, `data-guide-title`, `data-guide-links` and `data-guide-link`, so a theme can stand it on the shelf line at plate height. A guide tile with only `title`, `line` and `href` renders as before apart from the new hook attributes.
  • 60f76fc: `product-grid` goes live, gains the `shelf` variant and a real filter bar. (Bullets for the grid half of storefront S5; the tile, category-strip, featured-product, product-gallery and inventory-badge bullets are in the sibling changeset.) - **`dataSource: 'live'`.** A server renderer reads the listing source the host registered (`CatalogListingSource`) for the block's query: `category`, `tag`, `edition`, explicit `ids` ("goes with", which ignores every other filter), `sort` and `limit`. The default variants take the live tiles as their list, in the same markup. With no source registered, a static data source or any error, the authored `products` list renders exactly as before; the existing variants are byte-identical, held by a baseline test captured from the pre-change source. - **New variant `shelf`.** An opener (`kicker`, `heading`, `moreLink`, `showCount`; hooks `data-opener-*`), a four-across row (two on phones) of the shared product tile on a shelf line (`data-shelf-row`, `data-shelf-line`), a dashed `nextSlot` (`data-next-slot`), a `guideTile` placed after N pieces (`data-guide-tile`), `imageRole: 'second'` for the occasion row, and `tabs[] {label, tag}` that swap the row between tag queries (`data-grid-tabs`, `data-grid-tab` with `aria-pressed`). Every tab's row is rendered on the server and a tab is offered only when its query fills the row, so the swap is a client toggle with no fetch and the row never changes height. An empty state (`data-grid-empty`) offers clear filters and a link to the latest edition. - **`showFilters` is real on `shelf`.** Left `rail` or `bar` (`filterLayout`): numbered shelves with counts, checkbox facets with counts (empty options dimmed and not pickable), removable chips, clear all, sort, a live result count in an `aria-live="polite"` region, and paging by `pageSize` ("Show the next 6", a real link). The page URL holds the state: `?category=&attr.<axis>=a,b&price=&availability=in-stock&sort=&page=`, where `page` is cumulative. The host passes the URL in through the render context (`{ searchParams, pathname }`). Below 48rem the rail becomes sideways shelf tabs and a Filter button that opens a bottom sheet: focus moves in, Tab is trapped, Escape closes it and focus returns to the button. Every control has a label and a 44px target. - **Two client islands** (`ProductGridFilters`, `ProductGridTabs`); `ProductGridShelf` and the tiles stay server-safe. The rsc-boundaries manifest count for this pack goes from 1 to 3. Filter changes navigate through the link adapter, so a Next site gets soft navigation and a plain site a normal one, with no router dependency. - **Hook names** follow shelf/HOOK-CONTRACT for the grid, filter bar and sheet.
v0.37.3
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.37.0
v0.36.3patch

1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.

  • 1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.
v0.36.1
v0.36.0
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.28.0patch

483e0a1: The catalog pack's preview data now shows a fictional outdoor-gear shop instead of a real product line. Product card, product grid, featured product, price table, category strip and inventory badge previews use an invented trail-pack range and plan names. Block fields and variants are unchanged.

  • 483e0a1: The catalog pack's preview data now shows a fictional outdoor-gear shop instead of a real product line. Product card, product grid, featured product, price table, category strip and inventory badge previews use an invented trail-pack range and plan names. Block fields and variants are unchanged.
v0.27.0patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.25.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 6 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - ProductGrid (a client component) renders its stylesheet itself; `<BlockStyles>` works in client components since blocks-core 0.22.0. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 6 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - ProductGrid (a client component) renders its stylesheet itself; `<BlockStyles>` works in client components since blocks-core 0.22.0. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.20.1patch

cc6c65e: Fixes a badge/eyebrow overlap on narrow cards and two layout/contrast issues in the marketing-starter CTA and exhibit blocks. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's badge no longer overlaps the eyebrow text on narrow cards — below roughly 30rem of available width it drops out of its absolute-positioned corner and flows in-line above the eyebrow as a chip instead. `@wabbit/tome-blocks-marketing-starter`: the `cta` block's `doors` variant fixes a low-contrast underline on its "toolkit"-voice link, which was drawing its border from a surface-tint accent color instead of the paired text-safe accent token. The `exhibit` block's `gallery-wall` variant increases the vertical gap between its staggered plates at desktop widths so they no longer visually touch, and now centers the third plate on the grid when a wall has exactly three exhibits instead of leaving it flush left.

  • cc6c65e: Fixes a badge/eyebrow overlap on narrow cards and two layout/contrast issues in the marketing-starter CTA and exhibit blocks. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's badge no longer overlaps the eyebrow text on narrow cards — below roughly 30rem of available width it drops out of its absolute-positioned corner and flows in-line above the eyebrow as a chip instead. `@wabbit/tome-blocks-marketing-starter`: the `cta` block's `doors` variant fixes a low-contrast underline on its "toolkit"-voice link, which was drawing its border from a surface-tint accent color instead of the paired text-safe accent token. The `exhibit` block's `gallery-wall` variant increases the vertical gap between its staggered plates at desktop widths so they no longer visually touch, and now centers the third plate on the grid when a wall has exactly three exhibits instead of leaving it flush left.
  • 30a0060: Three display-family headings now read the `--tome-type-weight-display` token instead of a hard-coded weight. `@wabbit/tome-blocks-org-pack`: the `member-grid` block's heading and member-name headings now read the `--tome-type-weight-display` token (falling back to their existing 600 weight when a theme leaves it unset), so a theme that serves its display face at a lighter weight is no longer browser-faked bold. `@wabbit/tome-blocks-lms-pack`: the `course-card` block's title heading gets the same `--tome-type-weight-display` token treatment. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's title heading gets the same `--tome-type-weight-display` token treatment.
  • 7862f30: Swaps the plain primary token for the on-solid-dark pairing on text and borders that sit on a dark surface in three packs. `@wabbit/tome-blocks-dossier-pack`: the `evidence-sheet` block's `ledger` treatment now uses the on-solid-dark pairing for its kicker, headline emphasis, body link and row-label text, instead of the plain primary token — that token is a surface-tint fill, not guaranteed legible as text on the block's dark surface. `@wabbit/tome-blocks-cinema-pack`: the `scene-plate` block's seated panel kicker and body emphasis text get the same on-solid-dark pairing, since the panel itself is a partially-opaque dark surface over the scene image. `@wabbit/tome-blocks-catalog-pack`: the `price-table` block's highlighted-tier border now uses the on-solid-dark pairing specifically on the `dark` variant, leaving the default/light variant's border on the plain primary token unchanged.
v0.20.0
v0.19.1
v0.18.3patch

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

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

52cd0cf: Drops the no-op `peerDependenciesMeta` entry for `@wabbit/tome-catalog`, which was never a declared peer and is never imported.

  • 52cd0cf: Drops the no-op `peerDependenciesMeta` entry for `@wabbit/tome-catalog`, which was never a declared peer and is never imported.
v0.18.0patch

c3468b0: Layer detection now runs through blocks-core's `createLayerProbe` instead of a local `tryGetLayerRegistry` copy. The dynamic core import stays in this pack, and memo semantics are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: Layer detection now runs through blocks-core's `createLayerProbe` instead of a local `tryGetLayerRegistry` copy. The dynamic core import stays in this pack, and memo semantics are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.5patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.4patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.3patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.2patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.1patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0
v0.15.23patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.22patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.21patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.20patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.19patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.18patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.17patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.16patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.15patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.14patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.13patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
v0.15.9patch

Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9

  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9
v0.15.8patch

Updated dependencies [6779aa1] - @wabbit/tome-blocks-core@0.15.8

  • Updated dependencies [6779aa1] - @wabbit/tome-blocks-core@0.15.8
v0.15.7patch

@wabbit/tome-blocks-core@0.15.0

  • @wabbit/tome-blocks-core@0.15.0
v0.15.5patch

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.
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0
v0.13.0patch

Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0

  • Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0
v0.12.1patch

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.
v0.12.0minor

Neutralize pack-block base styling to the --tome-\* token system (extract-don't-delete; the brand treatment moved to @wabbit/tome-blocks-industrial-theme). - lms-pack: CourseCard.module.css rewritten tokens-only — the legacy safety-yellow CTA/featured strip, charcoal italic type, and hard-coded gray palette are gone from the base; the card now inherits the consuming site's theme. - catalog-pack: ProductGrid.module.css and CategoryStrip.module.css rewritten tokens-only (same extraction). - catalog-pack: FeaturedProduct, ProductCard, PriceTable, and InventoryBadge previously shipped NO styles and rendered as bare text stacks; each now has a neutral token-driven CSS module baseline (org-pack pattern), so they render designed-neutral on any consumer out of the box. Visual-breaking for consumers that relied on the baked-in industrial look: opt back in with @wabbit/tome-blocks-industrial-theme (one stylesheet import + data-tome-theme="industrial").

  • Neutralize pack-block base styling to the --tome-\* token system (extract-don't-delete; the brand treatment moved to @wabbit/tome-blocks-industrial-theme). - lms-pack: CourseCard.module.css rewritten tokens-only — the legacy safety-yellow CTA/featured strip, charcoal italic type, and hard-coded gray palette are gone from the base; the card now inherits the consuming site's theme. - catalog-pack: ProductGrid.module.css and CategoryStrip.module.css rewritten tokens-only (same extraction). - catalog-pack: FeaturedProduct, ProductCard, PriceTable, and InventoryBadge previously shipped NO styles and rendered as bare text stacks; each now has a neutral token-driven CSS module baseline (org-pack pattern), so they render designed-neutral on any consumer out of the box. Visual-breaking for consumers that relied on the baked-in industrial look: opt back in with @wabbit/tome-blocks-industrial-theme (one stylesheet import + data-tome-theme="industrial").
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2
v0.11.0minor

a5db69f: v1 hydration contract: new `@wabbit/tome-blocks-catalog-pack/server` subpath (`import 'server-only'`). `createHydratedRenderers({ getPayload })` returns server-component wrappers for `product-card`, `featured-product`, and `category-strip` — spread over the pack's static `components` map to overlay verified live `@wabbit/tome-catalog` fields on top of authored props, with zero client fetching and zero loading state. Per-block resolvers (`resolveProductCardData`, `resolveFeaturedProductData`, `resolveCategoryStripData`) are exported individually for use outside the block pipeline. The v1 matrix (verified against the real `@wabbit/tome-catalog` schema, not the original design guess): `product-card`/`featured-product` overlay `title` and `description` (from `Product.excerpt`) keyed by `sku` — `price`/`comparePrice`/`image`/CTA stay authored, since `@wabbit/tome-catalog` carries no pricing or stock field at all and neither block has an `image` render prop. `category-strip` overlays each item's `label` (from `Category.name`) keyed by `slug`, in one batched query — `url` stays authored and there is no live count (no render slot for one). `inventory-badge` is deliberately NOT hydrated in v1: `@wabbit/tome-catalog` models no stock/inventory field anywhere. `price-table` stays fully static by design (unchanged). `product-grid` is out of scope for this wave (its own `'use client'` boundary). Access boundary: every hydration query runs `overrideAccess: false` with no `user` — anonymous-visible data only (`publicReadActiveOnly` restricts Products to `status: active`; Categories are always public). Layer absent, entity missing, or a thrown query all fall back to the untouched authored props — never a crash, never an empty hole. Static usage is completely untouched: every block still renders exactly the authored props by default, with zero peer dependency and zero behavior change, whether or not a consumer ever imports `/server`.

  • a5db69f: v1 hydration contract: new `@wabbit/tome-blocks-catalog-pack/server` subpath (`import 'server-only'`). `createHydratedRenderers({ getPayload })` returns server-component wrappers for `product-card`, `featured-product`, and `category-strip` — spread over the pack's static `components` map to overlay verified live `@wabbit/tome-catalog` fields on top of authored props, with zero client fetching and zero loading state. Per-block resolvers (`resolveProductCardData`, `resolveFeaturedProductData`, `resolveCategoryStripData`) are exported individually for use outside the block pipeline. The v1 matrix (verified against the real `@wabbit/tome-catalog` schema, not the original design guess): `product-card`/`featured-product` overlay `title` and `description` (from `Product.excerpt`) keyed by `sku` — `price`/`comparePrice`/`image`/CTA stay authored, since `@wabbit/tome-catalog` carries no pricing or stock field at all and neither block has an `image` render prop. `category-strip` overlays each item's `label` (from `Category.name`) keyed by `slug`, in one batched query — `url` stays authored and there is no live count (no render slot for one). `inventory-badge` is deliberately NOT hydrated in v1: `@wabbit/tome-catalog` models no stock/inventory field anywhere. `price-table` stays fully static by design (unchanged). `product-grid` is out of scope for this wave (its own `'use client'` boundary). Access boundary: every hydration query runs `overrideAccess: false` with no `user` — anonymous-visible data only (`publicReadActiveOnly` restricts Products to `status: active`; Categories are always public). Layer absent, entity missing, or a thrown query all fall back to the untouched authored props — never a crash, never an empty hole. Static usage is completely untouched: every block still renders exactly the authored props by default, with zero peer dependency and zero behavior change, whether or not a consumer ever imports `/server`.
  • 6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.
  • 36e537a: Documentation truth pass: all "hydrates from @wabbit/tome-X when present" claims across READMEs, block meta, bundle descriptions, render headers, and admin field descriptions are rewritten to the honest contract — these blocks are fully static today; the layer-presence flags are the seam for a future hydration wave (trigger documented in place). content-writer's `RelatedPosts` (auto mode) and `Archive` (collection mode) no longer render fake placeholder UI — the unimplemented modes render nothing and say so in the admin field description.
  • 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.
  • 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0
v0.10.3patch

Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-blocks-core@0.10.0
v0.10.1patch

Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-blocks-core@0.10.0
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0
v0.9.4patch

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.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-core@1.2.1
v0.9.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2
v0.9.0patch

Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0

  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0
v0.8.0minor

249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.

  • 249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.
  • Updated dependencies [249b670] - @wabbit/tome-blocks-core@0.8.0
v0.7.0patch

Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0

  • Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [a9801fe]

  • Updated dependencies [a9801fe]
  • Updated dependencies [baf401e]
  • Updated dependencies [4b2f368] - @wabbit/tome-core@1.1.0 - @wabbit/tome-blocks-core@0.6.2
v0.5.9patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-blocks-core@0.5.9

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-blocks-core@0.5.9
v0.5.7patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11 - @wabbit/tome-blocks-core@0.5.7
v0.5.0minor

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

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

**Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.

  • **Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.
  • Updated dependencies - @wabbit/tome-blocks-core@0.4.2
v0.4.0patch

Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Editorial Pack

v0.49.8
v0.49.8patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
  • Updated dependencies [deea397] - @wabbit/tome-ui@0.22.3
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
  • Updated dependencies [d51ad89] - @wabbit/tome-ui@0.22.1
v0.42.1patch

Updated dependencies [9e7b353] - @wabbit/tome-ui@0.22.0

  • Updated dependencies [9e7b353] - @wabbit/tome-ui@0.22.0
v0.39.0minor

e192f5e: `info-panel` margin facts now land on the page grid's lanes, and the facts band takes a theme column count. - **Fix: the lanes.** The block's root was a block-level box between the wrapper `RenderBlocks` puts around every block (a subgrid of the page grid) and the section that subgrids it in turn. The section's `grid-template-columns: subgrid` therefore computed to `none`, and with `factsPlacement: 'margin'` the facts, the prose and the sidebar card all fell into one column from 1280px. The root now generates no box of its own (`display: contents`), so the section is a direct subgrid of the wrapper and the lanes exist. The framed variant (`showFrame`) keeps a box for its border. Markup is unchanged (the golden test still passes); only layout inside a page grid moves, and only for a block that relies on its named lines. - **`--tome-info-panel-facts-columns`.** The facts band from 1024px to 1279px reads this custom property for its `grid-template-columns` ("repeat(4, minmax(0, 1fr))"). Unset, it is as many 10rem columns as fit, as before. Purely additive in options: no field, peer or default changes.

  • e192f5e: `info-panel` margin facts now land on the page grid's lanes, and the facts band takes a theme column count. - **Fix: the lanes.** The block's root was a block-level box between the wrapper `RenderBlocks` puts around every block (a subgrid of the page grid) and the section that subgrids it in turn. The section's `grid-template-columns: subgrid` therefore computed to `none`, and with `factsPlacement: 'margin'` the facts, the prose and the sidebar card all fell into one column from 1280px. The root now generates no box of its own (`display: contents`), so the section is a direct subgrid of the wrapper and the lanes exist. The framed variant (`showFrame`) keeps a box for its border. Markup is unchanged (the golden test still passes); only layout inside a page grid moves, and only for a block that relies on its named lines. - **`--tome-info-panel-facts-columns`.** The facts band from 1024px to 1279px reads this custom property for its `grid-template-columns` ("repeat(4, minmax(0, 1fr))"). Unset, it is as many 10rem columns as fit, as before. Purely additive in options: no field, peer or default changes.
  • 6534727: `info-panel` can read the card's person on one line: "Jess, lead coach". - `personLine: 'inline'` (a select with no default, shown with the sticky card) renders a `<span data-panel-person-sep>, </span>` between the name and the role and marks the text `data-person-line="inline"`, so the name and role share a line. - Unset, `null` or `stacked` keeps the role under the name; stored documents render byte-identical markup. Purely additive: no peer is added or tightened. The new select field adds a column, so a site with migrations generates one.
  • 21672ef: `info-panel` can put the card's person after the button. - `personPlacement: 'after'` (a select with no default, shown with the sticky card) renders the lead person after the call-to-action button, in the DOM as well as on screen, so the reading and tab order match what the eye sees. A theme no longer needs CSS `order` to place the coach under the button. - Unset, `null` or `before` keeps the person under the kicker exactly as before; stored documents render byte-identical markup (the golden test still passes). Purely additive: no peer is added or tightened, and nothing changes for a site that does not set the field.
  • 179fe8c: `info-panel` can put its key facts in the left margin and host a live list in its sidebar card. - `factsPlacement: 'margin'` (a select with no default) moves the key facts out of the sidebar into their own region before the prose, `<aside data-key-facts-placement="margin">`, each fact reading label over value. The layout is a single column under 1024px, a band of facts above the prose from 1024px to 1279px, and from 1280px the page grid's lanes: facts in the left marginalia, prose in the reading lane, sidebar in the right marginalia. Unset or `card` keeps the facts in the sidebar exactly as before. - The renderer takes a `panelSlot` prop (a new `InfoPanelProps`, the same pattern as `contact`'s `formSlot`). A site passes any node, such as a list of the next sessions, and it renders inside the card in `[data-panel-slot]`, after the person and picture and before the key facts and button. A slot alone is enough to show the card. Omitted, nothing renders. Stored documents that set neither render byte-identical markup (pinned by a golden test). Purely additive: no peer is added or tightened, `factsPlacement` has no default, and nothing changes for a site that uses neither.
  • 0f42a97: `info-panel` can put a small tag beside a key fact's value and a one-line quote under the card person's role. - `keyFacts[].note` (optional text, localized): a status such as "Illustrative" or "Sample", rendered as `<span data-fact-note>` after the value text and not inside it. It shows in the sidebar list and in the margin placement. - `panelPerson.quote` (optional text, localized): one line in the person's own words, rendered as `<span data-panel-person-quote>` under the role and before the note, shown only when the person has a name. A block that sets neither renders exactly the markup it did before. Purely additive: no peer is added or tightened.
v0.38.0patch

Updated dependencies [a679cc1] - @wabbit/tome-ui@0.21.0

  • Updated dependencies [a679cc1] - @wabbit/tome-ui@0.21.0
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
  • f9fe8fe: Feature Hero shows icons instead of their names, and four blocks stay inside a phone-width window. - **Feature Hero:** a feature's `icon` is a lucide name ("shield-check") or an emoji, but the renderer printed the name as text in the card. It now uses extras' shared `resolveLucideIcon` (`./render/shared`, which extras has exported since 0.28.0), the same resolver extras and catalog-pack blocks use. An unknown name shows no icon, and emoji stay text. The extras map gains `shield-check` and `workflow`. - **Feature Hero grids:** the authored two- and three-column feature grids kept their columns on a phone and ran 380px past the window. Tracks are now `minmax(0, 1fr)`, and the grids stack below 640px. In a card with its image beside the text, the text drops below the image when it would get less than 10rem, instead of breaking words mid-way or pushing the card wider. - **Status Board:** the same fix to its card grid. It was 24px past its box at 375px and now stacks below 640px. - **Editorial Colophon:** its `3 / 11` placement counts the page grid's 16 desktop tracks, so below 1024px it overran into the right gutter. It sits on the content lines there and keeps its desktop placement. - **FAQ:** Full Width is padded by the page gutter, like Feature Hero's and CTA's Full Width.
v0.37.0minor

5c7752d: `annotated-page` gains a `voice` variant, two note kinds and lists in the paragraph editor. The `voice` variant sets an essay: no paragraph numbers, the text on the narrow prose track from 1024px, every note numbered with an anchor numeral at the end of its paragraph, and notes placed under their paragraph until 1280px and in the right margin from 1280px. It adds a `kicker` label, a `deck` (one sentence under the heading, `data-block-deck`), a `dropCap` checkbox and a `footnotesLabel`, and marks the root with `data-reading-body` for reading-time counters. Its layout is a stylesheet shared with the essay body of a post, and with a drop cap the first paragraph of the first row with text carries `data-drop-cap-target`. Type `[^*]` in a paragraph to place a footnote's mark where it is typed. New hooks: `data-annotated-part`, `data-note-number`, `data-fn-marker`, `data-footnotes`, `data-footnotes-label`, `data-footnote`, `data-footnote-mark`, `data-lane-kicker`, `data-drop-cap`, `data-notes-from`. New note kinds `exercise` (for a "Try this" prompt) and `footnote` (a symbol mark in the text, collected into a list after the last paragraph) work in both variants. The paragraph editor now offers ordered and unordered lists. Existing pages and the default variant render exactly as before.

  • 5c7752d: `annotated-page` gains a `voice` variant, two note kinds and lists in the paragraph editor. The `voice` variant sets an essay: no paragraph numbers, the text on the narrow prose track from 1024px, every note numbered with an anchor numeral at the end of its paragraph, and notes placed under their paragraph until 1280px and in the right margin from 1280px. It adds a `kicker` label, a `deck` (one sentence under the heading, `data-block-deck`), a `dropCap` checkbox and a `footnotesLabel`, and marks the root with `data-reading-body` for reading-time counters. Its layout is a stylesheet shared with the essay body of a post, and with a drop cap the first paragraph of the first row with text carries `data-drop-cap-target`. Type `[^*]` in a paragraph to place a footnote's mark where it is typed. New hooks: `data-annotated-part`, `data-note-number`, `data-fn-marker`, `data-footnotes`, `data-footnotes-label`, `data-footnote`, `data-footnote-mark`, `data-lane-kicker`, `data-drop-cap`, `data-notes-from`. New note kinds `exercise` (for a "Try this" prompt) and `footnote` (a symbol mark in the text, collected into a list after the last paragraph) work in both variants. The paragraph editor now offers ordered and unordered lists. Existing pages and the default variant render exactly as before.
  • be32808: New `./essay` entry sets a post in the Margin Voice, so an essay can be a Post and an editor works in the post editor. - `marginNoteBlock`: a `margin-note` Lexical block for a post body (`kind` with every note kind including `exercise` and `footnote`, `label`, `body`, `person`, and an optional `citationNumber`). A note attaches to the paragraph, list, heading or quote just above it; the admin says so. - `EssayBody`: groups a post body into voice rows and renders them with the same row renderer as the `annotated-page` `voice` variant. An `h2` opens a part, every note is numbered 1 to n in reading order with its anchor appended to its row, footnotes get symbol marks and an end list, and the drop cap comes from a post field. Uploads, banners and other blocks go to a `renderNode` callback. It emits `data-essay-body`, `data-reading-body` and the shared voice hooks, and it ships the layout itself: notes in the right margin from 1280px and inline below, text on the prose track, with the same stylesheet `annotated-page` `voice` uses (one source, mounted by `EssayBody` through `<BlockStyles>`), so a template imports no CSS. - `essayPostFields()`: the `essay` field group for a Posts collection (kind, revised date, "In short" summary, drop cap, running head on or off), shown for the `ESSAY` design version. - `MarginNoteInline`: a note in the flow of the text, for a post template with no margin. `marginNoteFallback()` reads a stored note as plain data for a serializer that cannot import the pack. - Footnote placement: type `[^*]` in a paragraph and the next footnote note's mark lands there instead of at the paragraph's end; footnotes with no token keep the end mark. It works in `annotated-page` too. - `essayReadingMinutes()`: reading time with margin-note text included, from core's reading-time rule. The entry has two halves, `./essay/config` (loads the Lexical editor runtime) and `./essay/render` (no Payload runtime; `EssayBody` mounts its layout stylesheet), and `./essay` joins both. `@wabbit/tome-core` becomes a required peer (`>=1.23.0 <2.0.0`) for the reading-time rule. The `margin-note` block turns off the editor's block-name input, which duplicated the label. Existing blocks and exports are unchanged.
  • Updated dependencies [d69161c] - @wabbit/tome-ui@0.20.0
v0.36.3patch

1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.

  • 1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.
v0.36.0minor

31a71f2: `info-panel` can hide the key-facts heading, and wraps each section sign in the section label in a `data-section-label-mark` span. The new `showKeyFactsLabel` checkbox (default on) shows or hides the heading above the key facts; content that never set it keeps the heading. Hidden, the facts sit in a `role="group"` whose `aria-label` is the key-facts label. A `§` or `¶` in the section label, linked or not, gets its own `<span data-section-label-mark="">`, so a theme can color the sign; text and accessible name are unchanged.

  • 31a71f2: `info-panel` can hide the key-facts heading, and wraps each section sign in the section label in a `data-section-label-mark` span. The new `showKeyFactsLabel` checkbox (default on) shows or hides the heading above the key facts; content that never set it keeps the heading. Hidden, the facts sit in a `role="group"` whose `aria-label` is the key-facts label. A `§` or `¶` in the section label, linked or not, gets its own `<span data-section-label-mark="">`, so a theme can color the sign; text and accessible name are unchanged.
v0.35.0patch

Updated dependencies [f7940ba] - @wabbit/tome-ui@0.19.0

  • Updated dependencies [f7940ba] - @wabbit/tome-ui@0.19.0
v0.34.0minor

937e9f3: `info-panel` can open a page, and its section label can leave mono. - The main rich text allows **h1** (it allowed h2 to h4), so a panel that heads a page can carry the page's `h1`. The prose rhythm styles h1 with the other headings. Existing content is unchanged. - The section label's voice is tokenised: `--tome-info-panel-label-font` (font family, default the mono face), `--tome-info-panel-label-case` (text-transform, default `uppercase`), `--tome-info-panel-label-tracking` (default the widest tracking) and `--tome-info-panel-label-caps` (font-variant-caps, default `normal`). A theme can set the label in small caps. The label carries `data-section-label`. - New optional `sectionLabelLink.text`: the label's leading part to link. With "Practice" on "Practice · Leases", only "Practice" is the link (`data-section-label-link`) and " · Leases" follows as `<span data-section-label-rest>`. Empty, or text the label doesn't start with, links the whole label as before.

  • 937e9f3: `info-panel` can open a page, and its section label can leave mono. - The main rich text allows **h1** (it allowed h2 to h4), so a panel that heads a page can carry the page's `h1`. The prose rhythm styles h1 with the other headings. Existing content is unchanged. - The section label's voice is tokenised: `--tome-info-panel-label-font` (font family, default the mono face), `--tome-info-panel-label-case` (text-transform, default `uppercase`), `--tome-info-panel-label-tracking` (default the widest tracking) and `--tome-info-panel-label-caps` (font-variant-caps, default `normal`). A theme can set the label in small caps. The label carries `data-section-label`. - New optional `sectionLabelLink.text`: the label's leading part to link. With "Practice" on "Practice · Leases", only "Practice" is the link (`data-section-label-link`) and " · Leases" follows as `<span data-section-label-rest>`. Empty, or text the label doesn't start with, links the whole label as before.
v0.32.0minor

164c8c2: A new Annotated Page block, plus a person note, photo-or-initials tile and linkable section label on the Info Panel. - **Annotated Page** (`annotated-page`, new): rows of prose, each with an automatic paragraph number and its own margin notes. Fields: an optional `heading` and `rows[] { subheading?, body, notes[] }`, where each note is `{ kind, label?, citationNumber?, person? { name, initials, role }, body? }` and `kind` is `note`, `person`, `citation`, `fee`, `timeline`, `jurisdiction` or `reviewed`. From 1024px the number sits in the left margin and the notes in the right margin, each row its own subgrid so a note lines up with its paragraph without JS; from 768px the notes move to the right margin; below 768px each note follows its paragraph inline. To cite a note, type `[^n]` in the paragraph and give a citation note `citationNumber: n`: the token renders as a superscript link to the note (`data-cite-marker`), and the note's number links back. A person note needs no photo (an initials tile). Theme hooks are listed in the README. - **Info Panel**: `panelPerson` gains an optional `note` (a small line under the role, `data-panel-person-note`) and an optional `photo`; without a photo the tile shows the initials and now also carries `data-panel-initials`. A new optional `sectionLabelLink { href }` turns the section label into a link (`data-section-label-link`). A new `panelPosition` select (`sticky`, the default, or `static`; shown with `stickyPanel` on) lets the card stay in place without sticky positioning; the sidebar then carries `data-panel-position="static"`. `stickyPanel` is effectively the switch that shows the card; it will be renamed in the block's next breaking version. All new fields are optional. Existing content renders as before.

  • 164c8c2: A new Annotated Page block, plus a person note, photo-or-initials tile and linkable section label on the Info Panel. - **Annotated Page** (`annotated-page`, new): rows of prose, each with an automatic paragraph number and its own margin notes. Fields: an optional `heading` and `rows[] { subheading?, body, notes[] }`, where each note is `{ kind, label?, citationNumber?, person? { name, initials, role }, body? }` and `kind` is `note`, `person`, `citation`, `fee`, `timeline`, `jurisdiction` or `reviewed`. From 1024px the number sits in the left margin and the notes in the right margin, each row its own subgrid so a note lines up with its paragraph without JS; from 768px the notes move to the right margin; below 768px each note follows its paragraph inline. To cite a note, type `[^n]` in the paragraph and give a citation note `citationNumber: n`: the token renders as a superscript link to the note (`data-cite-marker`), and the note's number links back. A person note needs no photo (an initials tile). Theme hooks are listed in the README. - **Info Panel**: `panelPerson` gains an optional `note` (a small line under the role, `data-panel-person-note`) and an optional `photo`; without a photo the tile shows the initials and now also carries `data-panel-initials`. A new optional `sectionLabelLink { href }` turns the section label into a link (`data-section-label-link`). A new `panelPosition` select (`sticky`, the default, or `static`; shown with `stickyPanel` on) lets the card stay in place without sticky positioning; the sidebar then carries `data-panel-position="static"`. `stickyPanel` is effectively the switch that shows the card; it will be renamed in the block's next breaking version. All new fields are optional. Existing content renders as before.
v0.31.0minor

8960ee1: Phase Ledger outcome hooks, an Info Panel card kicker, and two-column margin facts on phones. - **Phase Ledger** (dossier): each phase's outcome line carries `data-phase-outcome`, and its italic and weight read `--tome-dossier-phase-outcome-style` (default `italic`) and `--tome-dossier-phase-outcome-weight` (default `330`), so a theme can restyle the outcome without overriding the declarations. The look is unchanged when neither token is set. - **Info Panel** (editorial): the sticky panel card gains an optional `panelKicker`, a small label rendered first in the card, above the person (hook `data-panel-kicker`). It is read only with `stickyPanel` on, and a kicker alone is enough to render the card. The person's `name` may now be a full lead sentence ("Marisol Vance leads roof replacements."), with `role` as the sentences that follow. Both wrap beside the initials with no clamp or truncation, and the admin label and descriptions say so. Set `initials` when the name is a sentence. - **Editorial Spread** (editorial): margin facts (`rail.statCallouts` that carry a note) sit in two columns on phones (under 640px) instead of one long column. An odd last fact sits alone in the first column. Tablets keep the auto-fill columns, and the desktop rail is unchanged. Existing content renders as before.

  • 8960ee1: Phase Ledger outcome hooks, an Info Panel card kicker, and two-column margin facts on phones. - **Phase Ledger** (dossier): each phase's outcome line carries `data-phase-outcome`, and its italic and weight read `--tome-dossier-phase-outcome-style` (default `italic`) and `--tome-dossier-phase-outcome-weight` (default `330`), so a theme can restyle the outcome without overriding the declarations. The look is unchanged when neither token is set. - **Info Panel** (editorial): the sticky panel card gains an optional `panelKicker`, a small label rendered first in the card, above the person (hook `data-panel-kicker`). It is read only with `stickyPanel` on, and a kicker alone is enough to render the card. The person's `name` may now be a full lead sentence ("Marisol Vance leads roof replacements."), with `role` as the sentences that follow. Both wrap beside the initials with no clamp or truncation, and the admin label and descriptions say so. Set `initials` when the name is a sentence. - **Editorial Spread** (editorial): margin facts (`rail.statCallouts` that carry a note) sit in two columns on phones (under 640px) instead of one long column. An odd last fact sits alone in the first column. Tablets keep the auto-fill columns, and the desktop rail is unchanged. Existing content renders as before.
  • Updated dependencies [1767d0d] - @wabbit/tome-ui@0.18.0
v0.30.0minor

ee82782: `info-panel` with a sticky sidebar takes optional `panelPerson`, `panelMedia` and `panelCta`, rendered as one card: person, media thumb, key facts, then a button. The fields show only for the `Prose + Sidebar` treatment with **Sticky Sidebar** on. Person initials default to the first and last name. Theme hooks: `data-panel-card`, `data-panel-person`, `data-panel-person-initials`, `data-panel-person-name`, `data-panel-person-role`, `data-panel-media`, `data-panel-cta`. With none set, the panel renders as before. The panel's rich text now has paragraph spacing, heading rhythm and list markers in the block's own stylesheet, so lists and paragraphs read correctly under a CSS reset.

  • ee82782: `info-panel` with a sticky sidebar takes optional `panelPerson`, `panelMedia` and `panelCta`, rendered as one card: person, media thumb, key facts, then a button. The fields show only for the `Prose + Sidebar` treatment with **Sticky Sidebar** on. Person initials default to the first and last name. Theme hooks: `data-panel-card`, `data-panel-person`, `data-panel-person-initials`, `data-panel-person-name`, `data-panel-person-role`, `data-panel-media`, `data-panel-cta`. With none set, the panel renders as before. The panel's rich text now has paragraph spacing, heading rhythm and list markers in the block's own stylesheet, so lists and paragraphs read correctly under a CSS reset.
v0.29.0minor

1b46f4a: `editorialSpread` rail stat callouts take an optional `note`, rendered under the label in small muted type, so the rail can carry "margin facts" (value, label, note). **Migration:** this adds a field to the `editorialSpread` block, so run your Payload migration (`payload migrate:create`, then `payload migrate` on SQL databases) and regenerate your Payload types after upgrading. Existing spreads render unchanged. `rail.statCallouts[].note` is a localized text field capped at 120 characters. Below `lg`, where the rail collapses to an eyebrow and hides its details, stat callouts that carry a note now stay visible as a compact, hairline-separated list on their own row; bare value-and-label stats still collapse as before. Theme hooks: `[data-rail-facts]` on the list (with `data-has-notes="true"` when any note is set), `[data-rail-fact]` on each item and `[data-rail-fact-note]` on the note. The `rail-right` demo now shows margin facts.

  • 1b46f4a: `editorialSpread` rail stat callouts take an optional `note`, rendered under the label in small muted type, so the rail can carry "margin facts" (value, label, note). **Migration:** this adds a field to the `editorialSpread` block, so run your Payload migration (`payload migrate:create`, then `payload migrate` on SQL databases) and regenerate your Payload types after upgrading. Existing spreads render unchanged. `rail.statCallouts[].note` is a localized text field capped at 120 characters. Below `lg`, where the rail collapses to an eyebrow and hides its details, stat callouts that carry a note now stay visible as a compact, hairline-separated list on their own row; bare value-and-label stats still collapse as before. Theme hooks: `[data-rail-facts]` on the list (with `data-has-notes="true"` when any note is set), `[data-rail-fact]` on each item and `[data-rail-fact-note]` on the note. The `rail-right` demo now shows margin facts.
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
  • Updated dependencies [775f90a] - @wabbit/tome-ui@0.17.1
v0.28.2patch

Updated dependencies [d432a85] - @wabbit/tome-ui@0.17.0

  • Updated dependencies [d432a85] - @wabbit/tome-ui@0.17.0
v0.28.1patch

5884e65: The feature block's card grid now fits a 320px phone screen instead of running past the right edge. Every auto-fit track floor is capped at the container width (`min(<floor>, 100%)`).

  • 5884e65: The feature block's card grid now fits a 320px phone screen instead of running past the right edge. Every auto-fit track floor is capped at the container width (`min(<floor>, 100%)`).
v0.28.0patch

483e0a1: Preview data in the editorial pack now uses invented brand and people names. The compare columns, editorial spread and message panel previews use fictional names. Block fields and variants are unchanged.

  • 483e0a1: Preview data in the editorial pack now uses invented brand and people names. The compare columns, editorial spread and message panel previews use fictional names. Block fields and variants are unchanged.
  • 6301bf1: The data hero is now listed as a Tome block in the gallery, and its variant descriptions use plain layout names.
  • d08fc38: The info panel block now fits a phone-width screen: the sidebar stacks under the prose and the image wraps above it. The fixed 20rem sidebar track and non-shrinking image no longer force the block wider than its container. The layout from 768px up is unchanged.
  • 58655f4: Blocks now carry the neutral Tome source tag instead of the legacy source tag, and variant and editorial descriptions use plain layout names; stored content is unchanged.
v0.27.0patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
  • Updated dependencies [c14a133] - @wabbit/tome-ui@0.16.0
v0.26.0patch

f52288f: Per-word text no longer runs together, and `lexical-motion-text` is visible by default. `text-reveal` (editorial pack) and `lexical-motion-text` (extras) put the space after each word inside an `inline-block` span, where a trailing space collapses, so every word rendered glued to the next. The space is now a real text node between the word spans, so words separate, lines wrap between words, and copied text and screen readers get the sentence with spaces. `text-reveal` also drops empty words from leading or trailing whitespace. The block's meta, variant and field descriptions no longer claim a scroll-driven reveal that is not wired; the text was always fully visible. `lexical-motion-text` words rest visible and in place; the reveal now runs from a hidden first frame as an enhancement instead of ending there, so text shows without the animation. A `prefers-reduced-motion: reduce` rule shows the words with no animation. The effect is unchanged for visitors with motion enabled. No exports, fields or signatures change.

  • f52288f: Per-word text no longer runs together, and `lexical-motion-text` is visible by default. `text-reveal` (editorial pack) and `lexical-motion-text` (extras) put the space after each word inside an `inline-block` span, where a trailing space collapses, so every word rendered glued to the next. The space is now a real text node between the word spans, so words separate, lines wrap between words, and copied text and screen readers get the sentence with spaces. `text-reveal` also drops empty words from leading or trailing whitespace. The block's meta, variant and field descriptions no longer claim a scroll-driven reveal that is not wired; the text was always fully visible. `lexical-motion-text` words rest visible and in place; the reveal now runs from a hidden first frame as an enhancement instead of ending there, so text shows without the animation. A `prefers-reduced-motion: reduce` rule shows the words with no animation. The effect is unchanged for visitors with motion enabled. No exports, fields or signatures change.
  • Updated dependencies [8c84e70] - @wabbit/tome-ui@0.15.0
v0.23.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 12 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - EditorialSection and EditorialSpread are client components; each is now a server-safe wrapper (`X.tsx`) around `X.client.tsx`. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 12 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - EditorialSection and EditorialSpread are client components; each is now a server-safe wrapper (`X.tsx`) around `X.client.tsx`. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.20.1patch

aaf3533: Adds spacing between consecutive rich-text paragraphs and fixes a layout/interaction gap in two editorial variants. `@wabbit/tome-blocks-extras`: the `section-header` block's `statement-hero` and `ruled-split` variants now add space between consecutive paragraphs in their rich-text body copy, instead of every paragraph running edge to edge with no breathing room. `@wabbit/tome-blocks-editorial-pack`: the `feature` block's `walk-strip` and `ledger-dark` variants get the same consecutive-paragraph spacing in their rich-text body copy. `walk-strip`'s stop cards are now a single click target end to end (previously only the route link text itself was clickable), with a visible focus ring on the whole card when the link is keyboard-focused. `ledger-dark`'s USP grid now runs 2-up by default, 3-up at desktop widths, and 4-up at desktop widths specifically when there are exactly four entries — previously it was a fixed 3-up grid that collapsed straight to one column below tablet width. `walk-strip` also lays out a five-stop walk cleanly: five across at 1200px and wider, and 3 + 2 between 861 and 1199px, staggered on the middle column so no two cards touch. A five-stop walk previously wrapped its fifth stop alone onto a second row.

  • aaf3533: Adds spacing between consecutive rich-text paragraphs and fixes a layout/interaction gap in two editorial variants. `@wabbit/tome-blocks-extras`: the `section-header` block's `statement-hero` and `ruled-split` variants now add space between consecutive paragraphs in their rich-text body copy, instead of every paragraph running edge to edge with no breathing room. `@wabbit/tome-blocks-editorial-pack`: the `feature` block's `walk-strip` and `ledger-dark` variants get the same consecutive-paragraph spacing in their rich-text body copy. `walk-strip`'s stop cards are now a single click target end to end (previously only the route link text itself was clickable), with a visible focus ring on the whole card when the link is keyboard-focused. `ledger-dark`'s USP grid now runs 2-up by default, 3-up at desktop widths, and 4-up at desktop widths specifically when there are exactly four entries — previously it was a fixed 3-up grid that collapsed straight to one column below tablet width. `walk-strip` also lays out a five-stop walk cleanly: five across at 1200px and wider, and 3 + 2 between 861 and 1199px, staggered on the middle column so no two cards touch. A five-stop walk previously wrapped its fifth stop alone onto a second row.
v0.20.0patch

9ac8d3d: Data hero and text reveal now get their vertical padding: they read the undeclared `--tome-space-xxl` and now read `--tome-space-2xl`.

  • 9ac8d3d: Data hero and text reveal now get their vertical padding: they read the undeclared `--tome-space-xxl` and now read `--tome-space-2xl`.
  • Updated dependencies [9f6b52c] - @wabbit/tome-ui@0.14.0
v0.18.3patch

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

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

309b6d6: Message Panel and Split Panel timestamps now render in a pinned `en-US` locale and UTC instead of the server's runtime settings. They are formatted with `formatDisplayDate`; an unparseable timestamp renders as written instead of `Invalid Date`.

  • 309b6d6: Message Panel and Split Panel timestamps now render in a pinned `en-US` locale and UTC instead of the server's runtime settings. They are formatted with `formatDisplayDate`; an unparseable timestamp renders as written instead of `Invalid Date`.
  • cbbc37c: Text Reveal no longer promises a word-by-word scroll animation it does not have; its text renders fully visible, and its meta now says so. - `@wabbit/tome-blocks-editorial-pack`: the `text-reveal` block meta carries the corrected description, summary and role. - `@wabbit/tome-blocks-core`: the generated `BLOCK_CATALOG` entry for `text-reveal` picks up the same corrected description and role.
v0.18.0patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
  • Updated dependencies [404d325] - @wabbit/tome-ui@0.13.1
v0.16.3patch

9babc42: Omit nested-blocks fields when the consumer configured no allowlist, instead of emitting `blocks: []`. `editorialSpread`, `editorialSection` (`main`), `stackingWrapper` (`cards`) and `layoutGrid` (`items`) take their nested block allowlist from consumer config and defaulted it to `[]`, emitting the field regardless. That is not a harmless empty picker. Payload's client-config conversion guards both keys on length: ```js if (incomingField.blockReferences?.length) { ... } if (incomingField.blocks?.length) { ... } ``` so an empty array produces a client field carrying NEITHER key, and `@payloadcms/ui`'s `buildClientFieldSchemaMap` then evaluates `(field.blockReferences ?? field.blocks).map(...)` on undefined. It throws inside `renderDocument`, so **every** document edit view in the consuming admin renders blank or 500s — not only pages using the block. Observed 2026-09-09 on starter.wabbit.com, which registers these blocks with no config: Pages and Posts rendered an empty admin body while Media and Users were unaffected, with the REST layer healthy throughout. An unconfigured surface now degrades to absent rather than present-and-malformed. Consumers that do pass an allowlist are unchanged. Regression coverage lives in `blocks-editorial-pack/test/nested-blocks-allowlist.test.ts` and `blocks-extras/test/nested-blocks-allowlist.test.ts`; both gates were proven non-vacuous by reverting each guard and confirming the omission assertions fail. `layoutGrid` was found by sweeping the repo for the rest of the defect class rather than by a second field report — no consumer registers it today, so it was latent, not live. Any nested-blocks field whose allowlist is consumer-injected belongs to this class and must omit rather than emit empty.

  • 9babc42: Omit nested-blocks fields when the consumer configured no allowlist, instead of emitting `blocks: []`. `editorialSpread`, `editorialSection` (`main`), `stackingWrapper` (`cards`) and `layoutGrid` (`items`) take their nested block allowlist from consumer config and defaulted it to `[]`, emitting the field regardless. That is not a harmless empty picker. Payload's client-config conversion guards both keys on length: ```js if (incomingField.blockReferences?.length) { ... } if (incomingField.blocks?.length) { ... } ``` so an empty array produces a client field carrying NEITHER key, and `@payloadcms/ui`'s `buildClientFieldSchemaMap` then evaluates `(field.blockReferences ?? field.blocks).map(...)` on undefined. It throws inside `renderDocument`, so **every** document edit view in the consuming admin renders blank or 500s — not only pages using the block. Observed 2026-09-09 on starter.wabbit.com, which registers these blocks with no config: Pages and Posts rendered an empty admin body while Media and Users were unaffected, with the REST layer healthy throughout. An unconfigured surface now degrades to absent rather than present-and-malformed. Consumers that do pass an allowlist are unchanged. Regression coverage lives in `blocks-editorial-pack/test/nested-blocks-allowlist.test.ts` and `blocks-extras/test/nested-blocks-allowlist.test.ts`; both gates were proven non-vacuous by reverting each guard and confirming the omission assertions fail. `layoutGrid` was found by sweeping the repo for the rest of the defect class rather than by a second field report — no consumer registers it today, so it was latent, not live. Any nested-blocks field whose allowlist is consumer-injected belongs to this class and must omit rather than emit empty.
  • Updated dependencies [9babc42] - @wabbit/tome-blocks-extras@0.16.3 - @wabbit/tome-blocks-core@0.16.0
v0.16.1patch

Updated dependencies [befde64] - @wabbit/tome-ui@0.13.0

  • Updated dependencies [befde64] - @wabbit/tome-ui@0.13.0
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 04309f5: Fix a hydration mismatch in `Testimonial` and adopt the shared UTC-pinned date formatter across five renderers. `@wabbit/tome-blocks-marketing-starter`'s `Testimonial` is a `'use client'` component and rendered its publication date with a bare `new Date(iso).toLocaleDateString()`. That resolves against the RUNTIME locale and the RUNTIME time zone, so the server produced one string and the browser produced another and React reported a hydration mismatch on the block. The 2026-07-11 audit flagged it; it was still there on 2026-09-01. The same bare call sat in four server renderers — `blocks-editorial-pack`'s `MetricStrip` and `StatusBoard`, `blocks-agency-essentials`' `TeamRoster` (twice) and `Timeline`. No hydration mismatch there, but the rendered output changed with the deploy host's locale and offset, which is its own kind of wrong. All six call sites now use `formatDisplayDate` from `@wabbit/tome-blocks-core/utilities/formatDisplayDate` with an explicit `locale: 'en-US'`, a numeric `M/D/YYYY` `dateStyle` and `timeZone: 'UTC'`. The numeric shape is deliberate: it is byte-identical to what a US-locale runtime already produced, so this fixes the determinism without silently restyling anyone's dates. Every pack already declared `@wabbit/tome-blocks-core`, so no dependency changes. Shipped with its forcing function: a `no-restricted-syntax` rule in `eslint.config.mjs` warns on bare `toLocaleDateString`/`toLocaleString`/`toLocaleTimeString` in every `packages/*/src/**/*.tsx` and every block pack's `render/` directory, and points at `formatDisplayDate` and at `blocks-gallery`'s `ADDED_AT_FORMATTER` as the two accepted shapes.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0 - @wabbit/tome-blocks-extras@0.16.0 - @wabbit/tome-ui@0.12.0
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
  • Updated dependencies [54ff357] - @wabbit/tome-blocks-extras@0.15.12
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<color> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
  • Updated dependencies [1bebcdc]
  • Updated dependencies [48773ac] - @wabbit/tome-blocks-extras@0.15.11 - @wabbit/tome-ui@0.11.2
v0.15.4patch

Updated dependencies [0a070e0] - @wabbit/tome-ui@0.11.0

  • Updated dependencies [0a070e0] - @wabbit/tome-ui@0.11.0
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-extras@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0
v0.13.0minor

c041aea: feature: `walk-strip` + `ledger-dark` variants graduated from tome-starter (showcase Phase 3.7); `manifest` variant registered (closes doc/code drift — the variant array was missing an entry the doc comment and `designVersion` picker already described); `note` + `columnSpan` + USPs `link` added to the base schema. `manifest` is style-only, no schema change: it's the FEATURE_COP3 layout registered under its `_variant` slug, with a non-breaking fallback in the renderer (`block._variant === 'manifest'` applies the FEATURE_COP3 style class only when no legacy `designVersion` is set — existing FEATURE_COP1/2/3 documents render exactly as before). `walk-strip` is numbered stop cards — mono counter, serif stop title, body, and a real route link per stop, staggered offsets, scroll-reveal via `@wabbit/tome-blocks-core`'s `Reveal` helper; its per-USP `link` group lives on the base schema behind an `admin.condition` (not a `fieldOverrides` schema variant, same rationale as the cta door variants) using a newly-ported `createBlockItemCondition` helper (`src/shared/blockItemCondition.ts`, adapted from tome-starter's `findParentFeatureVersion` to not assume a hardcoded blocks-field name, since this package is consumed by sites that name it differently). `ledger-dark` is the ink full-bleed stat band — serif numerals with an accent superscript (a USP tagline of "96 free" splits into "96" + superscript "free"), mono uppercase labels over hairline rules, and a ruled `note` row (Ledger Dark only, behind an `admin.condition` on `_variant`); seed with `columnSpan: '1 / -1'` for full bleed.

  • c041aea: feature: `walk-strip` + `ledger-dark` variants graduated from tome-starter (showcase Phase 3.7); `manifest` variant registered (closes doc/code drift — the variant array was missing an entry the doc comment and `designVersion` picker already described); `note` + `columnSpan` + USPs `link` added to the base schema. `manifest` is style-only, no schema change: it's the FEATURE_COP3 layout registered under its `_variant` slug, with a non-breaking fallback in the renderer (`block._variant === 'manifest'` applies the FEATURE_COP3 style class only when no legacy `designVersion` is set — existing FEATURE_COP1/2/3 documents render exactly as before). `walk-strip` is numbered stop cards — mono counter, serif stop title, body, and a real route link per stop, staggered offsets, scroll-reveal via `@wabbit/tome-blocks-core`'s `Reveal` helper; its per-USP `link` group lives on the base schema behind an `admin.condition` (not a `fieldOverrides` schema variant, same rationale as the cta door variants) using a newly-ported `createBlockItemCondition` helper (`src/shared/blockItemCondition.ts`, adapted from tome-starter's `findParentFeatureVersion` to not assume a hardcoded blocks-field name, since this package is consumed by sites that name it differently). `ledger-dark` is the ink full-bleed stat band — serif numerals with an accent superscript (a USP tagline of "96 free" splits into "96" + superscript "free"), mono uppercase labels over hairline rules, and a ruled `note` row (Ledger Dark only, behind an `admin.condition` on `_variant`); seed with `columnSpan: '1 / -1'` for full bleed.
  • Updated dependencies [f4d55c9]
  • Updated dependencies [eb403d4] - @wabbit/tome-blocks-core@0.13.0 - @wabbit/tome-blocks-extras@0.13.0
v0.12.1patch

Updated dependencies - @wabbit/tome-ui@0.10.0

  • Updated dependencies - @wabbit/tome-ui@0.10.0
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2
v0.11.0minor

aef2725: EditorialSpread's two rail-rendering copies (RailInternals vs the rail-both left-rail IIFE) had silently drifted in field order — links-vs-statCallouts rendered in different orders per variant. Deduplicated into one `RailPrimaryContent` sub-component normalized to links → statCallouts. **VISIBLE CHANGE (hence minor)**: rail-left/rail-right/rail-top variants that populate BOTH links and statCallouts render them in the new order. Worth a glance on live editorial pages using both fields.

  • aef2725: EditorialSpread's two rail-rendering copies (RailInternals vs the rail-both left-rail IIFE) had silently drifted in field order — links-vs-statCallouts rendered in different orders per variant. Deduplicated into one `RailPrimaryContent` sub-component normalized to links → statCallouts. **VISIBLE CHANGE (hence minor)**: rail-left/rail-right/rail-top variants that populate BOTH links and statCallouts render them in the new order. Worth a glance on live editorial pages using both fields.
  • 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-blocks-extras@0.11.0 - @wabbit/tome-ui@0.9.9
v0.10.2patch

Updated dependencies [ec4b7bc] - @wabbit/tome-ui@0.9.8

  • Updated dependencies [ec4b7bc] - @wabbit/tome-ui@0.9.8
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0
v0.9.5patch

Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.

  • Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.
  • Updated dependencies - @wabbit/tome-blocks-extras@0.9.5
v0.9.4patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-extras@0.9.4 - @wabbit/tome-ui@0.9.7
v0.9.3patch

Updated dependencies - @wabbit/tome-ui@0.9.6

  • Updated dependencies - @wabbit/tome-ui@0.9.6
v0.9.2patch

Updated dependencies

  • Updated dependencies
  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-ui@0.9.5 - @wabbit/tome-blocks-extras@0.9.2
v0.9.1patch

Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1

  • Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1
v0.9.0minor

c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.

  • c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0
v0.8.0patch

Updated dependencies [249b670]

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

28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.

  • 28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.
  • 8958d41: Expose a client-safe `./meta` export on each block pack: payload-free block descriptor metadata (slug/name/description/category/tags/source + variants), separate from the payload-importing root barrel. This is the companion to the `./demo` export. Each block's `meta` literal is now extracted into a co-located payload-free `meta` module that the block config imports, and a pack-level `./meta` entry exposes the full descriptor list as `<pack>BlockMeta`. A consumer registering packs client-side (the wabbit `/blocks` gallery storefront, B6) can now read block metadata for gallery entries without importing the root barrel, which eagerly pulls each block's config (`payload` -> `richtext-lexical` -> `pino` -> `worker_threads`) into the browser bundle. Additive and behavior-preserving: `defineBlock` receives the same meta object (now imported rather than inline); the block registry, configs, demos, and existing exports are unchanged. The pack `BlockMeta` array is also re-exported from the root barrel for path-alias consumers.
  • Updated dependencies [28802fa]
  • Updated dependencies [66c611c]
  • Updated dependencies [8958d41] - @wabbit/tome-blocks-extras@0.7.0 - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2
v0.6.0minor

dfd8a78: Editorial Spread refinement + new Editorial Section block. - `editorialSpread`: expand from one `default` variant to five — `rail-left` (default), `rail-right`, `rail-top` (horizontal running head), `rail-both` (dual marginalia: structural left / referential right), `emphasis` (no-rail statement band). - Living rail: auto-derived index from a consumer-passed `ordinal` (manual `rail.index` still overrides), opt-in `rail.sticky`, `rail.links` (mini-nav), `rail.asides` (footnotes/asides); the `media` field re-homes from the main column into the rail. `kicker`/`index`/`caption`/`statCallouts` unchanged. `railSide` deprecated (superseded by the variant; retained for back-compat). - `.main` is now a subgrid pass-through so nested blocks resolve their own `breakoutWidth` (reading column by default, breakout for media/grids) — no new width system. - New `editorialSection` block: a banded reading-column section with NO rail — the lighter sibling for prose that doesn't need marginalia (reserves the spread for content that earns a rail). - README: documents the variants, the living rail, the consumer `ordinal` contract, and the usage doctrine (when to use Editorial Spread vs Editorial Section vs a standalone block). Backward-compatible: existing `editorialSpread` documents render unchanged (un-migrated docs fall back to `rail-left`/`railSide`). Consumers re-curate the `main` allowlist and wire the `ordinal` prop.

  • dfd8a78: Editorial Spread refinement + new Editorial Section block. - `editorialSpread`: expand from one `default` variant to five — `rail-left` (default), `rail-right`, `rail-top` (horizontal running head), `rail-both` (dual marginalia: structural left / referential right), `emphasis` (no-rail statement band). - Living rail: auto-derived index from a consumer-passed `ordinal` (manual `rail.index` still overrides), opt-in `rail.sticky`, `rail.links` (mini-nav), `rail.asides` (footnotes/asides); the `media` field re-homes from the main column into the rail. `kicker`/`index`/`caption`/`statCallouts` unchanged. `railSide` deprecated (superseded by the variant; retained for back-compat). - `.main` is now a subgrid pass-through so nested blocks resolve their own `breakoutWidth` (reading column by default, breakout for media/grids) — no new width system. - New `editorialSection` block: a banded reading-column section with NO rail — the lighter sibling for prose that doesn't need marginalia (reserves the spread for content that earns a rail). - README: documents the variants, the living rail, the consumer `ordinal` contract, and the usage doctrine (when to use Editorial Spread vs Editorial Section vs a standalone block). Backward-compatible: existing `editorialSpread` documents render unchanged (un-migrated docs fall back to `rail-left`/`railSide`). Consumers re-curate the `main` allowlist and wire the `ordinal` prop.
  • @wabbit/tome-blocks-core@0.5.9
v0.5.11patch

Updated dependencies [84a047a] - @wabbit/tome-ui@0.9.3 - @wabbit/tome-blocks-core@0.5.9

  • Updated dependencies [84a047a] - @wabbit/tome-ui@0.9.3 - @wabbit/tome-blocks-core@0.5.9
v0.5.9patch

Updated dependencies [8947ff1] - @wabbit/tome-ui@0.9.2 - @wabbit/tome-blocks-core@0.5.9 - @wabbit/tome-blocks-extras@0.5.9

  • Updated dependencies [8947ff1] - @wabbit/tome-ui@0.9.2 - @wabbit/tome-blocks-core@0.5.9 - @wabbit/tome-blocks-extras@0.5.9
v0.5.8patch

3f0c503: `editorialSpread`: align the rail breakpoint with where `@wabbit/tome-ui`'s marginalia track actually gains width. The rail was placed on the marginalia named track at `min-width: 768px`, but the marginalia tracks are zero-width until `lg` (1024px) — at `md` `marginalia-left-outer === marginalia-left-inner` — so on tablet the rail collapsed into a zero-width column and crammed the band (cramped `main`, nested blocks inheriting the squeeze). The side-rail placement now gates at `min-width: 1024px`, and the tight-eyebrow collapse extends from `max-width: 767px` to `max-width: 1023px` so the whole tablet range (768–1023) renders the rail as a compact kicker eyebrow above a full-width `main` instead of a tall vertical metadata stack. Desktop (`≥ 1024px`) side-rail layout is unchanged. - @wabbit/tome-blocks-core@0.5.7

  • 3f0c503: `editorialSpread`: align the rail breakpoint with where `@wabbit/tome-ui`'s marginalia track actually gains width. The rail was placed on the marginalia named track at `min-width: 768px`, but the marginalia tracks are zero-width until `lg` (1024px) — at `md` `marginalia-left-outer === marginalia-left-inner` — so on tablet the rail collapsed into a zero-width column and crammed the band (cramped `main`, nested blocks inheriting the squeeze). The side-rail placement now gates at `min-width: 1024px`, and the tight-eyebrow collapse extends from `max-width: 767px` to `max-width: 1023px` so the whole tablet range (768–1023) renders the rail as a compact kicker eyebrow above a full-width `main` instead of a tall vertical metadata stack. Desktop (`≥ 1024px`) side-rail layout is unchanged. - @wabbit/tome-blocks-core@0.5.7
v0.5.7patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
  • @wabbit/tome-blocks-extras@0.5.7
v0.5.3patch

`editorialSpread`: below the marginalia breakpoint (`< 768px`) the rail no longer forces its own row above `main` and pushes the content down. It now collapses to a tight kicker eyebrow directly above the heading (compact inline kicker, minimal gap), and the supplementary `railCaption` + `statCallouts` hide on narrow so the eyebrow doesn't re-expand into a pushing metadata block. Desktop (`≥ 768px`) side-rail placement is unchanged.

  • `editorialSpread`: below the marginalia breakpoint (`< 768px`) the rail no longer forces its own row above `main` and pushes the content down. It now collapses to a tight kicker eyebrow directly above the heading (compact inline kicker, minimal gap), and the supplementary `railCaption` + `statCallouts` hide on narrow so the eyebrow doesn't re-expand into a pushing metadata block. Desktop (`≥ 768px`) side-rail placement is unchanged.
v0.5.2patch

`editorialSpread` + `compareColumns`: the `contrast` / `contrast-deep` / `contrast-deepest` (dark/eggplant) bands now RE-SCOPE `--tome-color-foreground` (and the on-surface vars) to the inverted on-secondary value, so NESTED blocks (copyFocused, etc.) render light text on the dark band instead of dark-on-dark (reported via a consumer dark-section cascade-vars issue). Deep variants deepen toward `--tome-color-surface-solid-dark` instead of the now-re-scoped foreground.

  • `editorialSpread` + `compareColumns`: the `contrast` / `contrast-deep` / `contrast-deepest` (dark/eggplant) bands now RE-SCOPE `--tome-color-foreground` (and the on-surface vars) to the inverted on-secondary value, so NESTED blocks (copyFocused, etc.) render light text on the dark band instead of dark-on-dark (reported via a consumer dark-section cascade-vars issue). Deep variants deepen toward `--tome-color-surface-solid-dark` instead of the now-re-scoped foreground.
v0.5.1patch

`editorialSpread`: render the nested `main` composition via the CONSUMER-supplied `children` instead of the internal tome registry (`getAllRenderers`). A client block cannot render a consumer's SERVER nested blocks through a component map (the React RSC boundary), so the consumer's server `RenderBlocks` now renders `main` and passes the result as `children`; the shell lays out rail + band + slots it into the content column. Fixes empty `main` columns in consumers whose nested blocks are server components. Component header carries the consumer wiring snippet.

  • `editorialSpread`: render the nested `main` composition via the CONSUMER-supplied `children` instead of the internal tome registry (`getAllRenderers`). A client block cannot render a consumer's SERVER nested blocks through a component map (the React RSC boundary), so the consumer's server `RenderBlocks` now renders `main` and passes the result as `children`; the shell lays out rail + band + slots it into the content column. Fixes empty `main` columns in consumers whose nested blocks are server components. Component header carries the consumer wiring snippet.
v0.5.0minor

Add `editorialSpread` (rail + config-injected nested-blocks `main`, banded, subgrid-native) and `compareColumns` (two-column compare). Both consume the canonical `@wabbit/tome-ui` named-line breakout helper.

  • Add `editorialSpread` (rail + config-injected nested-blocks `main`, banded, subgrid-native) and `compareColumns` (two-column compare). Both consume the canonical `@wabbit/tome-ui` named-line breakout helper.
v0.4.2patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2
v0.4.1patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0
v0.4.0patch

Updated dependencies [b76f684]

  • Updated dependencies [b76f684]
  • Updated dependencies [90a694d] - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-extras@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Lms Pack

v0.49.4
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.46.0minor

c4b137a: New lesson-list, instructor-card and enrollment variants, plus course-index and skill-path-rows blocks. Adds the `open` lesson list, the `letter` and `one-line` instructor cards, the `split`, `buy-box` and `closing` enrollment CTAs, and two new blocks, `course-index` and `skill-path-rows`, with live course data when the LMS layer is present. Additive changes; every shipped block, variant and field renders exactly as before, and each new field is hidden in the admin unless its variant uses it: - **`lesson-list`** gains the `open` variant: every module open, lessons numbered across the course, module totals, a total line, a counts line ("2 free previews, 1 quiz, 1 assignment"), a locked state with its reason, and a `labels` group. With `courseSlug` set, `open` (and any variant when `createHydratedRenderers({ groupByModule: true })`) groups the live lessons by module, marks gated lessons locked, and names the lesson that opens them. The default hydration is still the flat single module. - **`instructor-card`** gains `letter` (a rail with a small face beside a printed-letter column) and `one-line` (a small face and one paragraph), with `image`, `imageAlt` (required with an image), `imagePosition`, `photoLabel`, `greeting`, `letter`, `signoff`, `location`, `lead`, `letterHref` and `letterLabel`. The image field uses the `mediaCollection` config option (default `media`). - **`enrollment-cta`** gains `split`, `buy-box` (sticky) and `closing`, with `courseSlug`, outcomes, computed totals, a secondary link, a prerequisite note, `ctaHrefTemplate`, `freeCtaHrefTemplate` and `labels`. With `courseSlug` set and the server renderers wired, the live price (the newest active one-time price of the course's catalog product, only when it is what checkout would charge), outcomes, modules, lessons, total time, prerequisite sentence and first free lesson overlay the authored values; anything that cannot be read keeps the authored value. - **`course-index`** (new) lists the school's courses as a typeset index, grouped by subject, with a course that begins every subject, and filters that are plain GET-parameter links (no script). The route passes `context={{ searchParams }}` to `RenderBlocks`; `readCourseIndexContext` is exported from the package root. - **`skill-path-rows`** (new) shows each active skill path as a row of its courses in order, with lessons, time and the price of buying them one at a time counted from the courses; a figure is dropped rather than understated when any course lacks it. - **`@wabbit/tome-blocks-lms-pack/server`**: `createHydratedRenderers` now also returns `enrollment-cta`, `course-index` and `skill-path-rows` and accepts `groupByModule`, `catalogProductsSlug` and `pricesSlug`; `wrapHydrated` forwards the render `context`; new exports `resolveEnrollmentCtaData`, `resolveCourseIndexData`, `resolveSkillPathRowsData`, `resolveCoursePrice` and `resolveCoursePrices`. `@wabbit/tome-lms` (`>=0.26.0 <1.0.0`) is a new optional peer, loaded lazily for the course outline and totals; without it every hydrated block keeps its authored values. No action is required for existing sites.

  • c4b137a: New lesson-list, instructor-card and enrollment variants, plus course-index and skill-path-rows blocks. Adds the `open` lesson list, the `letter` and `one-line` instructor cards, the `split`, `buy-box` and `closing` enrollment CTAs, and two new blocks, `course-index` and `skill-path-rows`, with live course data when the LMS layer is present. Additive changes; every shipped block, variant and field renders exactly as before, and each new field is hidden in the admin unless its variant uses it: - **`lesson-list`** gains the `open` variant: every module open, lessons numbered across the course, module totals, a total line, a counts line ("2 free previews, 1 quiz, 1 assignment"), a locked state with its reason, and a `labels` group. With `courseSlug` set, `open` (and any variant when `createHydratedRenderers({ groupByModule: true })`) groups the live lessons by module, marks gated lessons locked, and names the lesson that opens them. The default hydration is still the flat single module. - **`instructor-card`** gains `letter` (a rail with a small face beside a printed-letter column) and `one-line` (a small face and one paragraph), with `image`, `imageAlt` (required with an image), `imagePosition`, `photoLabel`, `greeting`, `letter`, `signoff`, `location`, `lead`, `letterHref` and `letterLabel`. The image field uses the `mediaCollection` config option (default `media`). - **`enrollment-cta`** gains `split`, `buy-box` (sticky) and `closing`, with `courseSlug`, outcomes, computed totals, a secondary link, a prerequisite note, `ctaHrefTemplate`, `freeCtaHrefTemplate` and `labels`. With `courseSlug` set and the server renderers wired, the live price (the newest active one-time price of the course's catalog product, only when it is what checkout would charge), outcomes, modules, lessons, total time, prerequisite sentence and first free lesson overlay the authored values; anything that cannot be read keeps the authored value. - **`course-index`** (new) lists the school's courses as a typeset index, grouped by subject, with a course that begins every subject, and filters that are plain GET-parameter links (no script). The route passes `context={{ searchParams }}` to `RenderBlocks`; `readCourseIndexContext` is exported from the package root. - **`skill-path-rows`** (new) shows each active skill path as a row of its courses in order, with lessons, time and the price of buying them one at a time counted from the courses; a figure is dropped rather than understated when any course lacks it. - **`@wabbit/tome-blocks-lms-pack/server`**: `createHydratedRenderers` now also returns `enrollment-cta`, `course-index` and `skill-path-rows` and accepts `groupByModule`, `catalogProductsSlug` and `pricesSlug`; `wrapHydrated` forwards the render `context`; new exports `resolveEnrollmentCtaData`, `resolveCourseIndexData`, `resolveSkillPathRowsData`, `resolveCoursePrice` and `resolveCoursePrices`. `@wabbit/tome-lms` (`>=0.26.0 <1.0.0`) is a new optional peer, loaded lazily for the course outline and totals; without it every hydrated block keeps its authored values. No action is required for existing sites.
  • e6933d8: Skill path rows can carry anchors so a link can land on the paths or on one path. New optional `anchorId` field on `skill-path-rows`. Set it (for example `paths`) and the section gets `id="paths"` and each row gets `id="path-{slug}"` (the path's slug, or a slug of its name for authored paths without one), so `/skill-paths#paths` and `/skill-paths#path-glaze-basics` work. Unset, no id is emitted and the markup is byte-identical. `PathRow` gains an optional `slug`, which the live resolver fills from the skill path.
  • 826ce7e: Enrollment CTAs can hide the course outcomes list. `enrollment-cta` gains `showOutcomes` (checkbox, default on, shown only on `split` and `buy-box`). Hydration filled `outcomes` from `Course.outcomes` whenever none were authored, so a course page that lists the outcomes in its main column (beside the syllabus) could not keep them out of the buy box. With `showOutcomes: false` the block prints no outcomes and no outcomes heading, authored or live, and the resolver fills none. Unset or `true` renders and hydrates exactly as before (a golden of the `split`, `buy-box` and `closing` markup cut before the field pins it); `default`, `minimal`, `dark` and `closing` ignore the field. Adds one optional column to the block's schema; no migration needed.
v0.44.0patch

024edf0: Internal Tome dependencies are published as compatible ranges, not exact versions, so installs share one copy of tome-core. Before, these packs pinned the exact tome-core version that was current when they shipped, so an install could end up with two copies of tome-core and split its shared registries. They now declare a compatible `^` range that npm can resolve to a single copy.

  • 024edf0: Internal Tome dependencies are published as compatible ranges, not exact versions, so installs share one copy of tome-core. Before, these packs pinned the exact tome-core version that was current when they shipped, so an install could end up with two copies of tome-core and split its shared registries. They now declare a compatible `^` range that npm can resolve to a single copy.
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.39.3
v0.39.1
v0.38.0minor

a721a6e: `course-card` gains an `offer` variant: a purchase card with a `kicker`, the title and description, a `price` with a `priceNote` ("Lifetime access"), one button whose `ctaLabel` may carry a `{price}` token ("Enroll · {price}"), and a `note` under it. It shows no image and no rating, and the `rating` field is hidden for it in the admin. The new fields have no default and show only for `offer`; `{price}` is only a token on `offer`. Hooks: `data-block-variant="offer"`, `data-course-kicker`, `data-course-price`, `data-course-price-note`, `data-course-action`, `data-course-note`. The other variants render exactly as before. The `offer` body also carries `data-course-body`, so a theme can lay the card out without a positional selector. The other variants are unchanged.

  • a721a6e: `course-card` gains an `offer` variant: a purchase card with a `kicker`, the title and description, a `price` with a `priceNote` ("Lifetime access"), one button whose `ctaLabel` may carry a `{price}` token ("Enroll · {price}"), and a `note` under it. It shows no image and no rating, and the `rating` field is hidden for it in the admin. The new fields have no default and show only for `offer`; `{price}` is only a token on `offer`. Hooks: `data-block-variant="offer"`, `data-course-kicker`, `data-course-price`, `data-course-price-note`, `data-course-action`, `data-course-note`. The other variants render exactly as before. The `offer` body also carries `data-course-body`, so a theme can lay the card out without a positional selector. The other variants are unchanged.
v0.37.3
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.37.0
v0.36.1
v0.36.0
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.28.0patch

483e0a1: The learning pack's preview data now shows a fictional baking course and instructor instead of platform training content. Course card, enrollment call to action, instructor card, lesson list, progress bar and quiz summary previews use an invented sourdough course. Block fields and variants are unchanged.

  • 483e0a1: The learning pack's preview data now shows a fictional baking course and instructor instead of platform training content. Course card, enrollment call to action, instructor card, lesson list, progress bar and quiz summary previews use an invented sourdough course. Block fields and variants are unchanged.
v0.27.0patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.26.0minor

47c18e2: All six LMS blocks now ship per-block stylesheets and render styled without the pack's global `styles.css`. `lesson-list`, `progress-bar`, `quiz-summary`, `instructor-card` and `enrollment-cta` no longer need `@wabbit/tome-blocks-lms-pack/styles.css` imported by the site. Before, those five depended on one global sheet with global class names and rendered completely unstyled (no error) without it. Each of the five renderers now has a sibling `<Name>.tome-css` and renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`, the same as `course-card`. Variants render as before. The `tome-*` class names stay on the elements as theming hooks but no longer carry rules, so a site stylesheet that targeted them for layout should target the element or its own class instead. **BREAKING:** required in the consuming site: wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); the `.tome-css` imports fail to build without it. See the blocks-core README, "Per-block stylesheets". - `./styles.css` is still exported and safe to keep importing; it now holds only the legacy course-card layer, and the other five blocks no longer read it. - `progress-bar`: the `role="progressbar"` track now has an accessible name (`aria-label` from the block's label, falling back to "Progress"). - Font sizes that match a platform type token now use `--tome-text-*`, letter-spacing `0.05em` uses `--tome-type-tracking-wider`, and transitions use `--tome-motion-*` with a `prefers-reduced-motion` path. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • 47c18e2: All six LMS blocks now ship per-block stylesheets and render styled without the pack's global `styles.css`. `lesson-list`, `progress-bar`, `quiz-summary`, `instructor-card` and `enrollment-cta` no longer need `@wabbit/tome-blocks-lms-pack/styles.css` imported by the site. Before, those five depended on one global sheet with global class names and rendered completely unstyled (no error) without it. Each of the five renderers now has a sibling `<Name>.tome-css` and renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`, the same as `course-card`. Variants render as before. The `tome-*` class names stay on the elements as theming hooks but no longer carry rules, so a site stylesheet that targeted them for layout should target the element or its own class instead. **BREAKING:** required in the consuming site: wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); the `.tome-css` imports fail to build without it. See the blocks-core README, "Per-block stylesheets". - `./styles.css` is still exported and safe to keep importing; it now holds only the legacy course-card layer, and the other five blocks no longer read it. - `progress-bar`: the `role="progressbar"` track now has an accessible name (`aria-label` from the block's label, falling back to "Progress"). - Font sizes that match a platform type token now use `--tome-text-*`, letter-spacing `0.05em` uses `--tome-type-tracking-wider`, and transitions use `--tome-motion-*` with a `prefers-reduced-motion` path. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.25.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 1 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - CourseCard (a client component) renders its stylesheet itself; `<BlockStyles>` works in client components since blocks-core 0.22.0. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 1 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - CourseCard (a client component) renders its stylesheet itself; `<BlockStyles>` works in client components since blocks-core 0.22.0. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.20.1patch

30a0060: Three display-family headings now read the `--tome-type-weight-display` token instead of a hard-coded weight. `@wabbit/tome-blocks-org-pack`: the `member-grid` block's heading and member-name headings now read the `--tome-type-weight-display` token (falling back to their existing 600 weight when a theme leaves it unset), so a theme that serves its display face at a lighter weight is no longer browser-faked bold. `@wabbit/tome-blocks-lms-pack`: the `course-card` block's title heading gets the same `--tome-type-weight-display` token treatment. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's title heading gets the same `--tome-type-weight-display` token treatment.

  • 30a0060: Three display-family headings now read the `--tome-type-weight-display` token instead of a hard-coded weight. `@wabbit/tome-blocks-org-pack`: the `member-grid` block's heading and member-name headings now read the `--tome-type-weight-display` token (falling back to their existing 600 weight when a theme leaves it unset), so a theme that serves its display face at a lighter weight is no longer browser-faked bold. `@wabbit/tome-blocks-lms-pack`: the `course-card` block's title heading gets the same `--tome-type-weight-display` token treatment. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's title heading gets the same `--tome-type-weight-display` token treatment.
v0.20.0
v0.19.1
v0.18.3
v0.18.1patch

efc584a: Drops the no-op `peerDependenciesMeta` entry for `@wabbit/tome-lms`, which was never a declared peer and is never imported; CSS now reaches `dist/` through a post-build script.

  • efc584a: Drops the no-op `peerDependenciesMeta` entry for `@wabbit/tome-lms`, which was never a declared peer and is never imported; CSS now reaches `dist/` through a post-build script.
v0.18.0patch

c3468b0: Layer detection now runs through blocks-core's `createLayerProbe` instead of a local `tryGetLayerRegistry` copy. The dynamic core import stays in this pack, and memo semantics are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: Layer detection now runs through blocks-core's `createLayerProbe` instead of a local `tryGetLayerRegistry` copy. The dynamic core import stays in this pack, and memo semantics are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.5patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.4patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.3patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.2patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.1patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0
v0.15.23patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.22patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.21patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.20patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.19patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.18patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.17patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.16patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.15patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.14patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.15.13patch

196d642: Remove a Star Citizen reference from the `instructor-card` editorial demo bio. Tome-native packages use domain-neutral language; domain vocabulary belongs in the sanctioned adaptation layer (`@wabbit/tome-sc`, `@wabbit/tome-blocks-sc-pack`), where consumers adapt neutral mechanisms to their own domain. `blocks-lms-pack` is a general-purpose LMS pack, so its shipped demo copy should not reference a specific game. Note the Star Citizen strings in `blocks-core`'s `BLOCK_CATALOG.ts` and `thumbnail-index.ts` are deliberately untouched: both are generated from the packs' own `meta.ts` descriptors and guarded by `scripts/assert-block-catalog.mjs`, and the strings originate in `blocks-sc-pack` where they are correct. An aggregation artifact is not a leak — what matters is where vocabulary is authored, not where it is compiled to. - @wabbit/tome-blocks-core@0.15.9

  • 196d642: Remove a Star Citizen reference from the `instructor-card` editorial demo bio. Tome-native packages use domain-neutral language; domain vocabulary belongs in the sanctioned adaptation layer (`@wabbit/tome-sc`, `@wabbit/tome-blocks-sc-pack`), where consumers adapt neutral mechanisms to their own domain. `blocks-lms-pack` is a general-purpose LMS pack, so its shipped demo copy should not reference a specific game. Note the Star Citizen strings in `blocks-core`'s `BLOCK_CATALOG.ts` and `thumbnail-index.ts` are deliberately untouched: both are generated from the packs' own `meta.ts` descriptors and guarded by `scripts/assert-block-catalog.mjs`, and the strings originate in `blocks-sc-pack` where they are correct. An aggregation artifact is not a leak — what matters is where vocabulary is authored, not where it is compiled to. - @wabbit/tome-blocks-core@0.15.9
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
v0.15.9patch

Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9

  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9
v0.15.8patch

Updated dependencies [6779aa1] - @wabbit/tome-blocks-core@0.15.8

  • Updated dependencies [6779aa1] - @wabbit/tome-blocks-core@0.15.8
v0.15.7patch

@wabbit/tome-blocks-core@0.15.0

  • @wabbit/tome-blocks-core@0.15.0
v0.15.5patch

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.
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0
v0.13.0patch

Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0

  • Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0
v0.12.1patch

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.
v0.12.0minor

Neutralize pack-block base styling to the --tome-\* token system (extract-don't-delete; the brand treatment moved to @wabbit/tome-blocks-industrial-theme). - lms-pack: CourseCard.module.css rewritten tokens-only — the legacy safety-yellow CTA/featured strip, charcoal italic type, and hard-coded gray palette are gone from the base; the card now inherits the consuming site's theme. - catalog-pack: ProductGrid.module.css and CategoryStrip.module.css rewritten tokens-only (same extraction). - catalog-pack: FeaturedProduct, ProductCard, PriceTable, and InventoryBadge previously shipped NO styles and rendered as bare text stacks; each now has a neutral token-driven CSS module baseline (org-pack pattern), so they render designed-neutral on any consumer out of the box. Visual-breaking for consumers that relied on the baked-in industrial look: opt back in with @wabbit/tome-blocks-industrial-theme (one stylesheet import + data-tome-theme="industrial").

  • Neutralize pack-block base styling to the --tome-\* token system (extract-don't-delete; the brand treatment moved to @wabbit/tome-blocks-industrial-theme). - lms-pack: CourseCard.module.css rewritten tokens-only — the legacy safety-yellow CTA/featured strip, charcoal italic type, and hard-coded gray palette are gone from the base; the card now inherits the consuming site's theme. - catalog-pack: ProductGrid.module.css and CategoryStrip.module.css rewritten tokens-only (same extraction). - catalog-pack: FeaturedProduct, ProductCard, PriceTable, and InventoryBadge previously shipped NO styles and rendered as bare text stacks; each now has a neutral token-driven CSS module baseline (org-pack pattern), so they render designed-neutral on any consumer out of the box. Visual-breaking for consumers that relied on the baked-in industrial look: opt back in with @wabbit/tome-blocks-industrial-theme (one stylesheet import + data-tome-theme="industrial").
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2
v0.11.0minor

8100b6f: Adds a new `@wabbit/tome-blocks-lms-pack/server` subpath: the v1 hydration contract. When @wabbit/tome-lms is present and an author sets a course-card or lesson-list block's new optional `courseSlug` field, a server component wrapper overlays live course data over the authored props before the static renderer runs — the renderer itself stays pure, unaware of hydration, and unchanged. Exports `createHydratedRenderers({ getPayload })`, which produces a drop-in `{ slug: Component }` map to spread over a static components map (`{ ...staticComponents, ...createHydratedRenderers({ getPayload }) }`), plus individually-exported per-block resolvers (`resolveCourseCardData`, `resolveLessonListData`) for hydrating outside the block pipeline. The v1 matrix (verified against the real `courses`/`course-items`/`lessons` schemas — the original design guess did not survive verification unchanged): - `course-card`: overlays `title`, `level`, `imageUrl`/`imageAlt` (from `featuredImage`), `instructor` (first of `Course.instructors`, name-or-email). Dropped: description/excerpt (Course.description is Lexical richText, incompatible with the authored plain-string field without a new richText-to-plaintext dependency this pack doesn't carry), price (lives in @wabbit/tome-catalog's `catalog-products`, a third optional composition layer beyond @wabbit/tome-lms itself — out of v1's single-layer scope), enrollment count (anonymous, `overrideAccess: false` reads of `course-enrollments` always return zero rows under that collection's access rules — hydrating it would show a permanently-wrong "0 enrolled"). - `lesson-list`: overlays a flattened, ordered list of live lesson titles, free-preview flags, and lesson types, sourced from `course-items` (always anonymously readable) with `lessons` populated at depth 1. Topic/module grouping is deferred to v2 (see the pack README). - `enrollment-cta` and `progress-bar` are NOT hydrated in v1. No anonymous-safe live field survived verification for the former (see the price/enrollment-state reasoning above — an anonymous course-read collapses "closed" and "not found" into the same result, so no live enrollment-state copy is derivable either). `progress-bar` needs per-user data, explicitly out of v1's anonymous-only scope (v2). Anonymous-access boundary: every hydration query runs with `overrideAccess: false` and no `user` — exactly what an anonymous site visitor could read. Enrollment-gated lesson content is never read (only title/order/lock-state metadata, never `content`/`blocks`/`video`). Per-request query dedup via `React.cache()` — no new cache/tag machinery in this pack. Static usage is completely unchanged: every block still renders exactly the authored props when `courseSlug` is unset, when @wabbit/tome-lms is absent, when the referenced course/lessons are missing, or if any query throws. Zero new `'use client'` directives were added (all new files live under `src/server/`, not `src/render/`).

  • 8100b6f: Adds a new `@wabbit/tome-blocks-lms-pack/server` subpath: the v1 hydration contract. When @wabbit/tome-lms is present and an author sets a course-card or lesson-list block's new optional `courseSlug` field, a server component wrapper overlays live course data over the authored props before the static renderer runs — the renderer itself stays pure, unaware of hydration, and unchanged. Exports `createHydratedRenderers({ getPayload })`, which produces a drop-in `{ slug: Component }` map to spread over a static components map (`{ ...staticComponents, ...createHydratedRenderers({ getPayload }) }`), plus individually-exported per-block resolvers (`resolveCourseCardData`, `resolveLessonListData`) for hydrating outside the block pipeline. The v1 matrix (verified against the real `courses`/`course-items`/`lessons` schemas — the original design guess did not survive verification unchanged): - `course-card`: overlays `title`, `level`, `imageUrl`/`imageAlt` (from `featuredImage`), `instructor` (first of `Course.instructors`, name-or-email). Dropped: description/excerpt (Course.description is Lexical richText, incompatible with the authored plain-string field without a new richText-to-plaintext dependency this pack doesn't carry), price (lives in @wabbit/tome-catalog's `catalog-products`, a third optional composition layer beyond @wabbit/tome-lms itself — out of v1's single-layer scope), enrollment count (anonymous, `overrideAccess: false` reads of `course-enrollments` always return zero rows under that collection's access rules — hydrating it would show a permanently-wrong "0 enrolled"). - `lesson-list`: overlays a flattened, ordered list of live lesson titles, free-preview flags, and lesson types, sourced from `course-items` (always anonymously readable) with `lessons` populated at depth 1. Topic/module grouping is deferred to v2 (see the pack README). - `enrollment-cta` and `progress-bar` are NOT hydrated in v1. No anonymous-safe live field survived verification for the former (see the price/enrollment-state reasoning above — an anonymous course-read collapses "closed" and "not found" into the same result, so no live enrollment-state copy is derivable either). `progress-bar` needs per-user data, explicitly out of v1's anonymous-only scope (v2). Anonymous-access boundary: every hydration query runs with `overrideAccess: false` and no `user` — exactly what an anonymous site visitor could read. Enrollment-gated lesson content is never read (only title/order/lock-state metadata, never `content`/`blocks`/`video`). Per-request query dedup via `React.cache()` — no new cache/tag machinery in this pack. Static usage is completely unchanged: every block still renders exactly the authored props when `courseSlug` is unset, when @wabbit/tome-lms is absent, when the referenced course/lessons are missing, or if any query throws. Zero new `'use client'` directives were added (all new files live under `src/server/`, not `src/render/`).
  • 6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.
  • 36e537a: Documentation truth pass: all "hydrates from @wabbit/tome-X when present" claims across READMEs, block meta, bundle descriptions, render headers, and admin field descriptions are rewritten to the honest contract — these blocks are fully static today; the layer-presence flags are the seam for a future hydration wave (trigger documented in place). content-writer's `RelatedPosts` (auto mode) and `Archive` (collection mode) no longer render fake placeholder UI — the unimplemented modes render nothing and say so in the admin field description.
  • 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.
  • 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0
v0.10.3patch

Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-blocks-core@0.10.0
v0.10.1patch

Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-blocks-core@0.10.0
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0
v0.9.4patch

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.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-core@1.2.1
v0.9.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2
v0.9.0patch

Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0

  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0
v0.8.0minor

249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.

  • 249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.
  • Updated dependencies [249b670] - @wabbit/tome-blocks-core@0.8.0
v0.7.0patch

Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0

  • Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [a9801fe]

  • Updated dependencies [a9801fe]
  • Updated dependencies [baf401e]
  • Updated dependencies [4b2f368] - @wabbit/tome-core@1.1.0 - @wabbit/tome-blocks-core@0.6.2
v0.5.9patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-blocks-core@0.5.9

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-blocks-core@0.5.9
v0.5.7patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11 - @wabbit/tome-blocks-core@0.5.7
v0.5.1patch

Add a `./styles.css` export to the package `exports` map so registry consumers can import the pack's compiled stylesheet (`@wabbit/tome-blocks-lms-pack/styles.css`). Under path-alias consumption the file resolved directly; the `exports` map enforces the subpath under registry consumption. Surfaced by tome-starter's `(frontend)/layout.tsx` during the move to registry consumption.

  • Add a `./styles.css` export to the package `exports` map so registry consumers can import the pack's compiled stylesheet (`@wabbit/tome-blocks-lms-pack/styles.css`). Under path-alias consumption the file resolved directly; the `exports` map enforces the subpath under registry consumption. Surfaced by tome-starter's `(frontend)/layout.tsx` during the move to registry consumption.
v0.5.0minor

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

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

**Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.

  • **Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.
  • Updated dependencies - @wabbit/tome-blocks-core@0.4.2
v0.4.0patch

Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Org Pack

v0.11.0
v0.11.0minor

2288bb9: `event-list` gains a `cards` variant: a still list of event cards with a date tile, the title, the time and place, an optional audience line, an optional price chip and a short description. Authored events take two new optional fields, `priceLabel` and `audience` (hooks `data-event-price` and `data-event-audience`); each prints only when entered. Live org events carry neither. The `default`, `filed` and `board` variants render exactly as before.

  • 2288bb9: `event-list` gains a `cards` variant: a still list of event cards with a date tile, the title, the time and place, an optional audience line, an optional price chip and a short description. Authored events take two new optional fields, `priceLabel` and `audience` (hooks `data-event-price` and `data-event-audience`); each prints only when entered. Live org events carry neither. The `default`, `filed` and `board` variants render exactly as before.
v0.10.0minor

e2f64c4: A save now moves renamed keys to their American names and empties the old ones. This is the second of three steps toward American spelling in stored values. On the class schedule block and collection, `enrolLabel`, `enrolHref`, `enrol` and `labels.enrol` move to `enrollLabel`, `enrollHref`, `enroll` and `labels.enroll`. On the event list board, `labels.cancelled` moves to `labels.canceled`. Anything that still writes an old key lands in the new one. This fixes clearing a field: a value sent for the new key, even a blank one, wins and the old value is emptied, so it no longer comes back on the next read or shows on the public page. The old group and label no longer have default values, so a new block stores nothing under them. Renderers still read the old keys, so a block that hasn't been saved or backfilled looks the same. The class schedule and event calendar demos use the new names. **BREAKING:** `@wabbit/tome-blocks-local-pack` now needs `@wabbit/tome-local` 0.6.0 or later, which exports these save hooks from `@wabbit/tome-local/classes`.

  • e2f64c4: A save now moves renamed keys to their American names and empties the old ones. This is the second of three steps toward American spelling in stored values. On the class schedule block and collection, `enrolLabel`, `enrolHref`, `enrol` and `labels.enrol` move to `enrollLabel`, `enrollHref`, `enroll` and `labels.enroll`. On the event list board, `labels.cancelled` moves to `labels.canceled`. Anything that still writes an old key lands in the new one. This fixes clearing a field: a value sent for the new key, even a blank one, wins and the old value is emptied, so it no longer comes back on the next read or shows on the public page. The old group and label no longer have default values, so a new block stores nothing under them. Renderers still read the old keys, so a block that hasn't been saved or backfilled looks the same. The class schedule and event calendar demos use the new names. **BREAKING:** `@wabbit/tome-blocks-local-pack` now needs `@wabbit/tome-local` 0.6.0 or later, which exports these save hooks from `@wabbit/tome-local/classes`.
v0.9.3patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v0.9.2patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.9.1patch

51f354c: A closure leaves the Today board when it ends, instead of staying at the top for the rest of the day. Before, a `board` with finished rows shown kept every closure that touched the day, so a "Field 5 closed · Until 3:30 PM" row still led the board after 3:30 PM while the facility it closed was open again. Now a closure is dropped once its end has passed (or its status is `completed`), on the server and in the resolver. The board's script removes the same row at the same instant on a page that was cached earlier, and hides a board that has no rows left, as the server would render it. Canceled closures and closures with no end are unchanged.

  • 51f354c: A closure leaves the Today board when it ends, instead of staying at the top for the rest of the day. Before, a `board` with finished rows shown kept every closure that touched the day, so a "Field 5 closed · Until 3:30 PM" row still led the board after 3:30 PM while the facility it closed was open again. Now a closure is dropped once its end has passed (or its status is `completed`), on the server and in the resolver. The board's script removes the same row at the same instant on a page that was cached earlier, and hides a board that has no rows left, as the server would render it. Canceled closures and closures with no end are unchanged.
v0.9.0minor

fd180de: The event list gains a live same-day `board` variant, facility links and a lead event; the division card can link its facility. Everything is opt-in: a block you already have renders exactly as before. - **`board` variant.** A new `event-list` variant (after `default` and `filed`) for a same-day board. Each row carries a state chip computed from its start and end in the block's time zone: "Live now", "Starts 6:30 PM" or "Finished", plus "Canceled". The clock decides, never the status text, and a row without a usable end gets no chip rather than a guess. In Today mode the board keeps today's finished events (new `showFinished`, on by default for the board) and lists them last, after closures and the events still to come, so the item cap drops finished rows first. Closure rows read "Until 3:30 PM". All the words are in a new `labels` group, including `{clock}` for a time without AM or PM. - **The board stays true on a cached page.** The board ships this pack's first client file, `EventBoardEnhance.client.tsx`, mounted only on `board`. It works on the rendered markup: at load it corrects any state that changed since the page was rendered, without announcing it; after that it sets one timer for the next start or end, re-checks when the tab comes back, and announces a real change once through a polite live region ("Under-12 final is now live"). At the end of the zone's day it hides the board rather than show yesterday. It never adds, removes or re-sorts rows. Without JavaScript the board shows the states true when it was rendered. Hooks: root `data-block-variant="board"`, `data-event-timezone`, `data-event-day-end`; rows `data-event-state`, `data-event-start`, `data-event-end`, `data-event-facility`; `span[data-event-state-chip]` in `[data-event-aside]`; `div[data-event-board-live]`. - **The chip state is a public function.** `eventChipState(entry, now)`, exported from the package root, is pure and isomorphic; the renderer and the island both call it, so they agree for any instant. - **Selection keeps finished events on request.** `selectEvents` takes `includeFinished` (Today mode only, default off). With it unset the function behaves exactly as before. - **Facility links.** Set `facilityHrefTemplate` (for example `#plan-{key}`) and give events a facility key, and each row's place becomes one link holding the facility's plate and name, `a[data-event-facility-link]` > `span[data-event-facility-plate]`. Plates come from the block's new `facilityPlates` list, else the event's own `facilityPlate`. Keys are lowercase letters, numbers and hyphens, up to 24 characters. A row without a key, or a blank template, links nothing. - **Reading the facility key and three more fields from your events.** `ResolveEventListOptions.fieldNames` gains `facilityKey`, `summary`, `registrationUrl` and `leadImage`. Each is read only when you name it, so the default query selects exactly what it did before. Invalid keys, summaries over 140 characters and unsafe links are dropped. The lead photo is read with one extra query, for the lead event only. The `/server` entry now exports `eventFacilityKeyField()`, a ready-made indexed text field with the key's validation, for your Event collection's `extraFields`, and `buildEventSelect`. - **Filed list: summaries, type tags and a lead event.** `showSummary` prints each event's one-line summary under its title (any variant). On `filed`, `showEventType` prints the event type as a tag (`eventTypeLabels` sets the words), and `leadLayout: 'first'` shows the first event as a lead with its photo and credit, a meta line ("Saturday and Sunday · Tournament", set by `leadMetaTemplate`), its summary and place, and "Event details" and "Register" links (`leadActions`). New hooks: `p[data-event-summary]`, `span[data-event-type]`, `article[data-event-lead]` with `figure[data-event-lead-media]`, `figcaption[data-event-lead-credit]`, `p[data-event-lead-meta]` and `a[data-event-lead-action="details|register"]`. - **Authored rows.** The block's own `events` rows gain optional `facilityKey`, `facilityPlate` and `summary`. - **Division card facility plate.** The `entry` variant gains optional `facilityKey`, `facilityPlate`, `facilityName` and `facilityHrefTemplate`. With a key and a template it prints a plate link just before the name, `a[data-division-facility-link]` > `span[data-division-facility-plate]`, named "Show Field 4 on the plan". Without them the card is unchanged. - **Choose the lead.** On `filed` with `leadLayout: 'first'`, `leadEvent` (an event id or slug) picks which event leads instead of the earliest, so one block can show a lead that is not first in date order. `excludeLeadFromRows` (default on once `leadEvent` is set) leaves the lead out of the rows; off lists it in both. The cap cannot drop the chosen lead, its photo is read for it, and a `leadEvent` the list does not select falls back to the first. `selectEvents` takes a matching `keep` option. With neither field set nothing changes. New fields are nullable columns: on a SQL database, regenerate your migration. **Checkpoint A: row times.** The time range on a row (every variant) now uses an en dash, "12:30 PM – 2:00 PM", where it printed a spaced hyphen, "12:30 PM - 2:00 PM". This is a deliberate fix to the default output, and the golden fixtures are updated for it; nothing else about the default changes. New opt-in `timeDisplay` (`range`, the default, or `start`): with `start`, a row prints the start time only. Board closure rows keep "Until 3:30 PM". The new field is a nullable column: on a SQL database, regenerate your migration.

  • fd180de: The event list gains a live same-day `board` variant, facility links and a lead event; the division card can link its facility. Everything is opt-in: a block you already have renders exactly as before. - **`board` variant.** A new `event-list` variant (after `default` and `filed`) for a same-day board. Each row carries a state chip computed from its start and end in the block's time zone: "Live now", "Starts 6:30 PM" or "Finished", plus "Canceled". The clock decides, never the status text, and a row without a usable end gets no chip rather than a guess. In Today mode the board keeps today's finished events (new `showFinished`, on by default for the board) and lists them last, after closures and the events still to come, so the item cap drops finished rows first. Closure rows read "Until 3:30 PM". All the words are in a new `labels` group, including `{clock}` for a time without AM or PM. - **The board stays true on a cached page.** The board ships this pack's first client file, `EventBoardEnhance.client.tsx`, mounted only on `board`. It works on the rendered markup: at load it corrects any state that changed since the page was rendered, without announcing it; after that it sets one timer for the next start or end, re-checks when the tab comes back, and announces a real change once through a polite live region ("Under-12 final is now live"). At the end of the zone's day it hides the board rather than show yesterday. It never adds, removes or re-sorts rows. Without JavaScript the board shows the states true when it was rendered. Hooks: root `data-block-variant="board"`, `data-event-timezone`, `data-event-day-end`; rows `data-event-state`, `data-event-start`, `data-event-end`, `data-event-facility`; `span[data-event-state-chip]` in `[data-event-aside]`; `div[data-event-board-live]`. - **The chip state is a public function.** `eventChipState(entry, now)`, exported from the package root, is pure and isomorphic; the renderer and the island both call it, so they agree for any instant. - **Selection keeps finished events on request.** `selectEvents` takes `includeFinished` (Today mode only, default off). With it unset the function behaves exactly as before. - **Facility links.** Set `facilityHrefTemplate` (for example `#plan-{key}`) and give events a facility key, and each row's place becomes one link holding the facility's plate and name, `a[data-event-facility-link]` > `span[data-event-facility-plate]`. Plates come from the block's new `facilityPlates` list, else the event's own `facilityPlate`. Keys are lowercase letters, numbers and hyphens, up to 24 characters. A row without a key, or a blank template, links nothing. - **Reading the facility key and three more fields from your events.** `ResolveEventListOptions.fieldNames` gains `facilityKey`, `summary`, `registrationUrl` and `leadImage`. Each is read only when you name it, so the default query selects exactly what it did before. Invalid keys, summaries over 140 characters and unsafe links are dropped. The lead photo is read with one extra query, for the lead event only. The `/server` entry now exports `eventFacilityKeyField()`, a ready-made indexed text field with the key's validation, for your Event collection's `extraFields`, and `buildEventSelect`. - **Filed list: summaries, type tags and a lead event.** `showSummary` prints each event's one-line summary under its title (any variant). On `filed`, `showEventType` prints the event type as a tag (`eventTypeLabels` sets the words), and `leadLayout: 'first'` shows the first event as a lead with its photo and credit, a meta line ("Saturday and Sunday · Tournament", set by `leadMetaTemplate`), its summary and place, and "Event details" and "Register" links (`leadActions`). New hooks: `p[data-event-summary]`, `span[data-event-type]`, `article[data-event-lead]` with `figure[data-event-lead-media]`, `figcaption[data-event-lead-credit]`, `p[data-event-lead-meta]` and `a[data-event-lead-action="details|register"]`. - **Authored rows.** The block's own `events` rows gain optional `facilityKey`, `facilityPlate` and `summary`. - **Division card facility plate.** The `entry` variant gains optional `facilityKey`, `facilityPlate`, `facilityName` and `facilityHrefTemplate`. With a key and a template it prints a plate link just before the name, `a[data-division-facility-link]` > `span[data-division-facility-plate]`, named "Show Field 4 on the plan". Without them the card is unchanged. - **Choose the lead.** On `filed` with `leadLayout: 'first'`, `leadEvent` (an event id or slug) picks which event leads instead of the earliest, so one block can show a lead that is not first in date order. `excludeLeadFromRows` (default on once `leadEvent` is set) leaves the lead out of the rows; off lists it in both. The cap cannot drop the chosen lead, its photo is read for it, and a `leadEvent` the list does not select falls back to the first. `selectEvents` takes a matching `keep` option. With neither field set nothing changes. New fields are nullable columns: on a SQL database, regenerate your migration. **Checkpoint A: row times.** The time range on a row (every variant) now uses an en dash, "12:30 PM – 2:00 PM", where it printed a spaced hyphen, "12:30 PM - 2:00 PM". This is a deliberate fix to the default output, and the golden fixtures are updated for it; nothing else about the default changes. New opt-in `timeDisplay` (`range`, the default, or `start`): with `start`, a row prints the start time only. Board closure rows keep "Until 3:30 PM". The new field is a nullable column: on a SQL database, regenerate your migration.
  • 024edf0: Internal Tome dependencies are published as compatible ranges, not exact versions, so installs share one copy of tome-core. Before, these packs pinned the exact tome-core version that was current when they shipped, so an install could end up with two copies of tome-core and split its shared registries. They now declare a compatible `^` range that npm can resolve to a single copy.
v0.8.1patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.8.0minor

11468e2: `division-card` gains an `entry` variant: a program or team entry with a captioned photo, a lead line, the name, the purpose, an authored next date and a link. The text comes first in the DOM (`data-division-text`, with `data-division-lead`, `-name`, `-body`, `-next` and `-link`), then the photo as `figure[data-division-media]` with its label and credit in a `figcaption` after the image, never on it. `mediaSide` places the photo on the start or end from 48rem without reordering the DOM; `headingLevel` sets the name as an `h3` (default) or `h2`; `*run*` in the lead line sets a weight-only emphasis. With no photo the text spans. The photo upload relates to `media` by default and to `divisionCardBlock.block({ mediaCollection })` when given. `memberCount`, `logoUrl` and `leaderName` are hidden for `entry` and ignored. The `default` variant is unchanged: its markup is held byte for byte by a golden cut from the pre-change renderer, and it gains no attribute.

  • 11468e2: `division-card` gains an `entry` variant: a program or team entry with a captioned photo, a lead line, the name, the purpose, an authored next date and a link. The text comes first in the DOM (`data-division-text`, with `data-division-lead`, `-name`, `-body`, `-next` and `-link`), then the photo as `figure[data-division-media]` with its label and credit in a `figcaption` after the image, never on it. `mediaSide` places the photo on the start or end from 48rem without reordering the DOM; `headingLevel` sets the name as an `h3` (default) or `h2`; `*run*` in the lead line sets a weight-only emphasis. With no photo the text spans. The photo upload relates to `media` by default and to `divisionCardBlock.block({ mediaCollection })` when given. `memberCount`, `logoUrl` and `leaderName` are hidden for `entry` and ignored. The `default` variant is unchanged: its markup is held byte for byte by a golden cut from the pre-change renderer, and it gains no attribute.
v0.7.4
v0.7.3
v0.7.2
v0.7.1patch

15c9de3: Security/visibility fix: the live `event-list` resolver now fetches only the fields it renders, so it can never pull an event's briefing, organizer or co-hosts. `resolveEventListData` passes a Payload `select` (title, slug, dates, status, type, location and the capacity counters, following any renamed fields). It works with `@wabbit/tome-org`'s new opt-in public read rule (`publicRead: { events: true }`): anonymous visitors then see the live public events instead of the authored fallback. The authored fallback still applies when the org layer is absent or the read is refused.

  • 15c9de3: Security/visibility fix: the live `event-list` resolver now fetches only the fields it renders, so it can never pull an event's briefing, organizer or co-hosts. `resolveEventListData` passes a Payload `select` (title, slug, dates, status, type, location and the capacity counters, following any renamed fields). It works with `@wabbit/tome-org`'s new opt-in public read rule (`publicRead: { events: true }`): anonymous visitors then see the live public events instead of the authored fallback. The authored fallback still applies when the org layer is absent or the read is refused.
v0.7.0minor

afe9fe7: Adds the `event-list` block: a live list of org events with an optional "N spots left" chip, a "today" mode that renders nothing on quiet days, and an optional .ics link per event. It reads upcoming, same-day or date-range events through the new `./server` entry (`createHydratedRenderers`), as an anonymous visitor and public events only, and falls back to its authored events when no wrapper is wired or a read fails. The chip shows "N spots left", "Full" or "Waitlist open" and is hidden when capacity data is missing. Rows carry `data-event-*` theme hooks and ship a `default` and a `filed` (dated rows) variant. `capacityChip`, `buildEventIcs`, `eventIcsDataUri` and `selectEvents` are exported from the root. `event-calendar` and `event-list-item` are unchanged.

  • afe9fe7: Adds the `event-list` block: a live list of org events with an optional "N spots left" chip, a "today" mode that renders nothing on quiet days, and an optional .ics link per event. It reads upcoming, same-day or date-range events through the new `./server` entry (`createHydratedRenderers`), as an anonymous visitor and public events only, and falls back to its authored events when no wrapper is wired or a read fails. The chip shows "N spots left", "Full" or "Waitlist open" and is hidden when capacity data is missing. Rows carry `data-event-*` theme hooks and ship a `default` and a `filed` (dated rows) variant. `capacityChip`, `buildEventIcs`, `eventIcsDataUri` and `selectEvents` are exported from the root. `event-calendar` and `event-list-item` are unchanged.
v0.6.8patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.6.7
v0.6.6
v0.6.5
v0.6.4patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.6.3patch

5884e65: The event-list-item block now fits a 320px phone screen: the date moves above the title and the title track can shrink. The fixed date track no longer competes with the status chip at phone width.

  • 5884e65: The event-list-item block now fits a 320px phone screen: the date moves above the title and the title track can shrink. The fixed date track no longer competes with the status chip at phone width.
v0.6.2patch

483e0a1: The organization pack's event calendar preview now uses a fictional venue name. The event calendar preview locations name an invented headquarters. Block fields and variants are unchanged.

  • 483e0a1: The organization pack's event calendar preview now uses a fictional venue name. The event calendar preview locations name an invented headquarters. Block fields and variants are unchanged.
  • d08fc38: The event calendar block no longer overflows a phone-width screen; each event's date moves to its own row. The fixed date column is kept from 768px up. Below that, the date sits above the title and status, and long titles wrap.
v0.6.1patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.6.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 8 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 8 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.5.6patch

30a0060: Three display-family headings now read the `--tome-type-weight-display` token instead of a hard-coded weight. `@wabbit/tome-blocks-org-pack`: the `member-grid` block's heading and member-name headings now read the `--tome-type-weight-display` token (falling back to their existing 600 weight when a theme leaves it unset), so a theme that serves its display face at a lighter weight is no longer browser-faked bold. `@wabbit/tome-blocks-lms-pack`: the `course-card` block's title heading gets the same `--tome-type-weight-display` token treatment. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's title heading gets the same `--tome-type-weight-display` token treatment.

  • 30a0060: Three display-family headings now read the `--tome-type-weight-display` token instead of a hard-coded weight. `@wabbit/tome-blocks-org-pack`: the `member-grid` block's heading and member-name headings now read the `--tome-type-weight-display` token (falling back to their existing 600 weight when a theme leaves it unset), so a theme that serves its display face at a lighter weight is no longer browser-faked bold. `@wabbit/tome-blocks-lms-pack`: the `course-card` block's title heading gets the same `--tome-type-weight-display` token treatment. `@wabbit/tome-blocks-catalog-pack`: the `featured-product` block's title heading gets the same `--tome-type-weight-display` token treatment.
v0.5.5patch

9ac8d3d: Seven org blocks now render their muted panels and heading sizes instead of falling back to nothing. Affected: campaign banner, document link, event calendar, event list, member card, member grid and org chart. They read `--tome-color-muted`, `--tome-text-xl`, `--tome-text-2xl` and `--tome-text-base` without a fallback, and no package declares those names. They now read `--tome-color-surface-muted`, `--tome-type-size-xl`, `--tome-type-size-2xl` and `--tome-text-body`.

  • 9ac8d3d: Seven org blocks now render their muted panels and heading sizes instead of falling back to nothing. Affected: campaign banner, document link, event calendar, event list, member card, member grid and org chart. They read `--tome-color-muted`, `--tome-text-xl`, `--tome-text-2xl` and `--tome-text-base` without a fallback, and no package declares those names. They now read `--tome-color-surface-muted`, `--tome-type-size-xl`, `--tome-type-size-2xl` and `--tome-text-body`.
v0.5.4
v0.5.3patch

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

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

2d8409e: Drops the no-op `peerDependenciesMeta` entry for `@wabbit/tome-org`, which was never a declared peer and is never imported.

  • 2d8409e: Drops the no-op `peerDependenciesMeta` entry for `@wabbit/tome-org`, which was never a declared peer and is never imported.
v0.5.1patch

c3468b0: Layer detection now runs through blocks-core's `createLayerProbe` instead of a local `tryGetLayerRegistry` copy. The dynamic core import stays in this pack, and memo semantics are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: Layer detection now runs through blocks-core's `createLayerProbe` instead of a local `tryGetLayerRegistry` copy. The dynamic core import stays in this pack, and memo semantics are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.5.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.4.5patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.4.4patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.4.3patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.4.2patch

@wabbit/tome-blocks-core@0.16.0

  • @wabbit/tome-blocks-core@0.16.0
v0.4.1patch

c1f3830: Demo imagery now resolves without a network. Every org-pack image field is a plain-text URL (no Payload upload), and the demos pointed at `https://cdn.wabbit.example/…`, a domain that does not exist — so member-card, member-grid, division-card and campaign-banner rendered broken-image glyphs in any gallery preview or thumbnail capture. New `demo-media.ts` emits a self-contained SVG data URI per subject (neutral tonal field + initials; no brand color), and `DemoContext` gains `placeholderImageUrl` so a host can substitute real imagery. Found while registering Org Blocks on wabbit.com/blocks (2026-09-05).

  • c1f3830: Demo imagery now resolves without a network. Every org-pack image field is a plain-text URL (no Payload upload), and the demos pointed at `https://cdn.wabbit.example/…`, a domain that does not exist — so member-card, member-grid, division-card and campaign-banner rendered broken-image glyphs in any gallery preview or thumbnail capture. New `demo-media.ts` emits a self-contained SVG data URI per subject (neutral tonal field + initials; no brand color), and `DemoContext` gains `placeholderImageUrl` so a host can substitute real imagery. Found while registering Org Blocks on wabbit.com/blocks (2026-09-05).
  • Updated dependencies - @wabbit/tome-blocks-core@0.16.0
v0.4.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • 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.
  • 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.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0
v0.3.17patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.16patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.15patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.14patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.13patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.12patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.11patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.10patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.9patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.8patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.7patch

@wabbit/tome-blocks-core@0.15.9

  • @wabbit/tome-blocks-core@0.15.9
v0.3.6patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
v0.3.5patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
v0.3.4patch

Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9

  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9
v0.3.3patch

6779aa1: Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration). - **blocks-org-pack:** CampaignBanner's `codename` field is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGrid `rank` fields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout. - **tome-org:** flavored admin LABELS get neutral text while stored values stay put — Event status `boarding`/`debrief` labeled "Check-In"/"Wrap-Up"; eventType `operation`/`patrol`/`exam` labeled "Initiative"/"Outreach"/"Assessment"; `securityLevel` labeled "Access"; Campaign `codename` labeled "Internal Name" and campaignType `recurring_op`/`special_operation`/`deployment` labeled "Recurring Series"/"Special Initiative"/"Rollout"; Member `classification` labeled "Directory Visibility" with `classified` labeled "Private", "Chain of command" → "Reporting line"; Rank `securityClearance` labeled "Access Level" and category `command` labeled "Management"; Squad squadType `fire_team`/`flight` labeled "Crew"/"Pod", `callsign` labeled "Nickname"; Membership `squadron` labeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", category `command` labeled "Executive". The configurable `DEFAULT_ORG_TERMINOLOGY` (Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and `@wabbit/tome-sc` inherits it for its themed collections. - **blocks-core:** BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).

  • 6779aa1: Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration). - **blocks-org-pack:** CampaignBanner's `codename` field is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGrid `rank` fields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout. - **tome-org:** flavored admin LABELS get neutral text while stored values stay put — Event status `boarding`/`debrief` labeled "Check-In"/"Wrap-Up"; eventType `operation`/`patrol`/`exam` labeled "Initiative"/"Outreach"/"Assessment"; `securityLevel` labeled "Access"; Campaign `codename` labeled "Internal Name" and campaignType `recurring_op`/`special_operation`/`deployment` labeled "Recurring Series"/"Special Initiative"/"Rollout"; Member `classification` labeled "Directory Visibility" with `classified` labeled "Private", "Chain of command" → "Reporting line"; Rank `securityClearance` labeled "Access Level" and category `command` labeled "Management"; Squad squadType `fire_team`/`flight` labeled "Crew"/"Pod", `callsign` labeled "Nickname"; Membership `squadron` labeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", category `command` labeled "Executive". The configurable `DEFAULT_ORG_TERMINOLOGY` (Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and `@wabbit/tome-sc` inherits it for its themed collections. - **blocks-core:** BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).
  • Updated dependencies [6779aa1] - @wabbit/tome-blocks-core@0.15.8
v0.3.2patch

@wabbit/tome-blocks-core@0.15.0

  • @wabbit/tome-blocks-core@0.15.0
v0.3.1patch

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.
v0.3.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0
v0.2.10patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0
v0.2.9patch

Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0

  • Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0
v0.2.8patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2
v0.2.7patch

6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.

  • 6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.
  • 36e537a: Documentation truth pass: all "hydrates from @wabbit/tome-X when present" claims across READMEs, block meta, bundle descriptions, render headers, and admin field descriptions are rewritten to the honest contract — these blocks are fully static today; the layer-presence flags are the seam for a future hydration wave (trigger documented in place). content-writer's `RelatedPosts` (auto mode) and `Archive` (collection mode) no longer render fake placeholder UI — the unimplemented modes render nothing and say so in the admin field description.
  • 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0
v0.2.6patch

bbe8945: 0.2.5 published to npm.wabbit.com without its `dist/` output: the tarball contained only `package.json`/`README.md`/`CHANGELOG.md`/`LICENSE.md` (4.5 kB) even though `files` declares `dist` and `main`/`module`/`exports` all point into it, breaking every consumer's install (missing entry point). Root cause: 0.2.5 was a pure dependency-ripple patch (blocks-core 0.10.0 bump, no source change to this package) published via `pnpm publish` with no lifecycle guard verifying `dist/` existed at pack time — `pnpm pack`/`publish` silently omits a listed `files` entry when the path is absent rather than erroring, and this package (like its blocks-family siblings) had no `prepublishOnly` build guard. Reproduced locally: `rm -rf dist && pnpm pack` produced the identical 4-file/~1.6kB-unpacked artifact; with `dist/` present, `pnpm pack` correctly emits 276 files / 63.9 kB, matching the healthy 0.2.4 baseline. Fix: added `"prepublishOnly": "pnpm run build"` to this package's scripts. tsup's own config already sets `clean: true`, so a plain build both clears stale output and guarantees `dist/` exists before `pnpm publish` packs the tarball — verified via `pnpm publish --dry-run --no-git-checks` with `dist/` deleted beforehand: the hook rebuilt dist and the resulting dry-run tarball matched the 276-file/63.9 kB healthy shape. This is a republish, not a code change — no runtime behavior differs from what 0.2.4/0.2.5-intended shipped. Note (not fixed here — out of scope for this package's patch): this dist-less-publish class of defect is repo-wide, since no package in the monorepo has a `prepublishOnly`/`prepack` build guard prior to this change (confirmed via `grep -l prepublishOnly packages/*/package.json` returning nothing). Any package published without a preceding fresh build is equally exposed. Recommend a shared guard (e.g. a `scripts/verify-dist-before-publish.mjs` invoked from each package's `prepublishOnly`, or a root `pnpm publish` wrapper that runs `pnpm --filter <pkg>... build` first) rather than hand-adding `prepublishOnly: "pnpm run build"` to all ~30 packages individually. Separately (also not fixed here): this package's pre-existing `"clean": "rimraf dist"` script is independently broken — `rimraf` is not declared in this package's `devDependencies` (nor at the workspace root), so `pnpm run clean` fails with "'rimraf' is not recognized" if invoked directly. The same gap exists across the whole blocks family (blocks, blocks-core, blocks-extras, and all 8 sibling packs) plus `core`, `ui`, `motion`, `lms`, `lms-ui`, `gamification` — none declare `rimraf` even though their `clean` script calls it; sibling packages like `admin`, `crm`, `marketing`, `accounts`, `org`, `sc` do declare it (`^5.0.0`). This guard's `prepublishOnly` avoids the gap by not calling `clean` at all (relying on tsup's own `clean: true`), so it is unaffected, but the standalone `clean` script remains latent-broken for these ~18 packages. - @wabbit/tome-blocks-core@0.10.0

  • bbe8945: 0.2.5 published to npm.wabbit.com without its `dist/` output: the tarball contained only `package.json`/`README.md`/`CHANGELOG.md`/`LICENSE.md` (4.5 kB) even though `files` declares `dist` and `main`/`module`/`exports` all point into it, breaking every consumer's install (missing entry point). Root cause: 0.2.5 was a pure dependency-ripple patch (blocks-core 0.10.0 bump, no source change to this package) published via `pnpm publish` with no lifecycle guard verifying `dist/` existed at pack time — `pnpm pack`/`publish` silently omits a listed `files` entry when the path is absent rather than erroring, and this package (like its blocks-family siblings) had no `prepublishOnly` build guard. Reproduced locally: `rm -rf dist && pnpm pack` produced the identical 4-file/~1.6kB-unpacked artifact; with `dist/` present, `pnpm pack` correctly emits 276 files / 63.9 kB, matching the healthy 0.2.4 baseline. Fix: added `"prepublishOnly": "pnpm run build"` to this package's scripts. tsup's own config already sets `clean: true`, so a plain build both clears stale output and guarantees `dist/` exists before `pnpm publish` packs the tarball — verified via `pnpm publish --dry-run --no-git-checks` with `dist/` deleted beforehand: the hook rebuilt dist and the resulting dry-run tarball matched the 276-file/63.9 kB healthy shape. This is a republish, not a code change — no runtime behavior differs from what 0.2.4/0.2.5-intended shipped. Note (not fixed here — out of scope for this package's patch): this dist-less-publish class of defect is repo-wide, since no package in the monorepo has a `prepublishOnly`/`prepack` build guard prior to this change (confirmed via `grep -l prepublishOnly packages/*/package.json` returning nothing). Any package published without a preceding fresh build is equally exposed. Recommend a shared guard (e.g. a `scripts/verify-dist-before-publish.mjs` invoked from each package's `prepublishOnly`, or a root `pnpm publish` wrapper that runs `pnpm --filter <pkg>... build` first) rather than hand-adding `prepublishOnly: "pnpm run build"` to all ~30 packages individually. Separately (also not fixed here): this package's pre-existing `"clean": "rimraf dist"` script is independently broken — `rimraf` is not declared in this package's `devDependencies` (nor at the workspace root), so `pnpm run clean` fails with "'rimraf' is not recognized" if invoked directly. The same gap exists across the whole blocks family (blocks, blocks-core, blocks-extras, and all 8 sibling packs) plus `core`, `ui`, `motion`, `lms`, `lms-ui`, `gamification` — none declare `rimraf` even though their `clean` script calls it; sibling packages like `admin`, `crm`, `marketing`, `accounts`, `org`, `sc` do declare it (`^5.0.0`). This guard's `prepublishOnly` avoids the gap by not calling `clean` at all (relying on tsup's own `clean: true`), so it is unaffected, but the standalone `clean` script remains latent-broken for these ~18 packages. - @wabbit/tome-blocks-core@0.10.0
v0.2.5patch

@wabbit/tome-blocks-core@0.10.0

  • @wabbit/tome-blocks-core@0.10.0
v0.2.4patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0
v0.2.3patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4
v0.2.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2
v0.2.1patch

Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0

  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0
v0.2.0minor

249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.

  • 249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.
  • Updated dependencies [249b670] - @wabbit/tome-blocks-core@0.8.0
v0.1.7patch

Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0

  • Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0
v0.1.6patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2
v0.1.5patch

@wabbit/tome-blocks-core@0.5.9

  • @wabbit/tome-blocks-core@0.5.9
v0.1.4patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
v0.1.3patch

Onboard to the dist-publish pipeline — **first registry publish** to npm.wabbit.com (the pack was previously source-only / path-alias consumption). Adds a `tsup` build (`bundle: false`, dual ESM/CJS, `'use client'` preserved, CSS mirrored to `dist/`), dist-pointing `exports` (`.` config + `./render` components), and `files` / `publishConfig` / `license` / `author` / `repository` metadata. No source or behavior change. Enables registry consumption (e.g. tome-starter moving off path-alias-to-source).

  • Onboard to the dist-publish pipeline — **first registry publish** to npm.wabbit.com (the pack was previously source-only / path-alias consumption). Adds a `tsup` build (`bundle: false`, dual ESM/CJS, `'use client'` preserved, CSS mirrored to `dist/`), dist-pointing `exports` (`.` config + `./render` components), and `files` / `publishConfig` / `license` / `author` / `repository` metadata. No source or behavior change. Enables registry consumption (e.g. tome-starter moving off path-alias-to-source).
  • Updated dependencies - @wabbit/tome-blocks-core@0.4.2
v0.1.2patch

Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0
v0.1.1patch

Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Dossier Pack

v0.22.0
v0.22.0minor

191df17: term-ledger: new `split` variant (the first two rows as two contrasting cards, the rest as a row of short columns, on the tinted band) and an optional `rows[].cardTitle` that the split cards show over the term. comparison-table: optional `footnoteFigure` and `footnoteFigureLabel` turn the footnote into a callout panel with a big figure beside it. Additive: existing blocks render unchanged.

  • 191df17: term-ledger: new `split` variant (the first two rows as two contrasting cards, the rest as a row of short columns, on the tinted band) and an optional `rows[].cardTitle` that the split cards show over the term. comparison-table: optional `footnoteFigure` and `footnoteFigureLabel` turn the footnote into a callout panel with a big figure beside it. Additive: existing blocks render unchanged.
v0.21.0minor

807c889: Practice sites can now build a full first-visit page and a provider index from blocks. All additive: existing blocks render as before unless a page selects the new options. - `practice-steps` adds a `long` variant: details and a how-long and who panel per step. - `practice-people-list` adds a `directory` variant: labeled facts and one action along the card bottoms. - `practice-record-header` adds a typed `page` variant for a page with no record behind it. - `practice-book` adds a typed `bookLink` for a page with no person behind it. - `proof-plates` plates can carry a checklist. - The Rounds theme styles all of these. Its closing book block now sets the words beside stacked choices from 1024px, the caption over the words, and the current nav link is marked.

  • 807c889: Practice sites can now build a full first-visit page and a provider index from blocks. All additive: existing blocks render as before unless a page selects the new options. - `practice-steps` adds a `long` variant: details and a how-long and who panel per step. - `practice-people-list` adds a `directory` variant: labeled facts and one action along the card bottoms. - `practice-record-header` adds a typed `page` variant for a page with no record behind it. - `practice-book` adds a typed `bookLink` for a page with no person behind it. - `proof-plates` plates can carry a checklist. - The Rounds theme styles all of these. Its closing book block now sets the words beside stacked choices from 1024px, the caption over the words, and the current nav link is marked.
v0.20.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.20.0minor

3f7bbee: `proof-plates` gains stable hooks that themes can style. `data-proof-plates` is on the plate row, and `data-proof-plate`, `data-proof-kicker`, `data-proof-title`, `data-proof-body` and `data-proof-link` are on each plate and its parts (the link hook is on the anchor, inline or button). Attributes only: no schema change, no new field, no class or structure change. A golden file captured before the hooks existed, with the hooks stripped, matches the new markup in every shell. `PackButtons` gains an optional `attrs` prop for `data-*` attributes on each anchor; unset, it adds none.

  • 3f7bbee: `proof-plates` gains stable hooks that themes can style. `data-proof-plates` is on the plate row, and `data-proof-plate`, `data-proof-kicker`, `data-proof-title`, `data-proof-body` and `data-proof-link` are on each plate and its parts (the link hook is on the anchor, inline or button). Attributes only: no schema change, no new field, no class or structure change. A golden file captured before the hooks existed, with the hooks stripped, matches the new markup in every shell. `PackButtons` gains an optional `attrs` prop for `data-*` attributes on each anchor; unset, it adds none.
v0.19.0minor

dac1155: Term Ledger can show a second figure, figure captions and a link under the rows. Four optional fields on `term-ledger`: `figureCaption`, `secondFigureMedia` (same upload and media collection as `figureMedia`), `secondFigureCaption`, and a `link` group (`label`, `href`, `style`: `text`, `button` or `outline`, default `text`). Captions render as a `<figcaption>` inside the figure; a second figure follows the first in the same figure column (in a `data-term-figures` wrapper); the link renders after the rows. New hooks for themes: `data-term-figure="1|2"` (emitted only when one of the new fields is used), `data-term-caption`, `data-term-figures` and `data-term-link`. With all four fields unset the markup is byte-identical to before (pinned by the existing defaults golden and a field-list golden).

  • dac1155: Term Ledger can show a second figure, figure captions and a link under the rows. Four optional fields on `term-ledger`: `figureCaption`, `secondFigureMedia` (same upload and media collection as `figureMedia`), `secondFigureCaption`, and a `link` group (`label`, `href`, `style`: `text`, `button` or `outline`, default `text`). Captions render as a `<figcaption>` inside the figure; a second figure follows the first in the same figure column (in a `data-term-figures` wrapper); the link renders after the rows. New hooks for themes: `data-term-figure="1|2"` (emitted only when one of the new fields is used), `data-term-caption`, `data-term-figures` and `data-term-link`. With all four fields unset the markup is byte-identical to before (pinned by the existing defaults golden and a field-list golden).
v0.18.2patch

1636ffa: The `practice-modes` demo's closing link is the caller's `placeholderHref` or `#`, not an invented `/practice/modes`.

  • 1636ffa: The `practice-modes` demo's closing link is the caller's `placeholderHref` or `#`, not an invented `/practice/modes`.
v0.18.1patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.18.0minor

16eb14c: `phase-ledger` gains a `pinned` variant, and `record-roster` can set client names in type and carry one quote. Both are opt-in; stored documents and defaults render byte-identical. `pinned` is a numbered list of phases that sticks beside one 3:2 plate per phase (from 1024px), each plate captioned with a label line ("Phase 1 · Brief", from the new `phaseLabel` template), the phase's sentence, a `caption` and a `credit`. A small client island (`PhaseLedgerPinnedTracking`, one IntersectionObserver) marks the phase whose plate is in view with ink and a small accent square. Without JavaScript every phase shows; under reduced motion there is no easing but the active phase still changes. The four existing variants and the `panel` island are unchanged. From 1024px the plates are one track and plate 1 no longer keeps its phone span of two tracks, so every plate has the full column. New phase fields: `caption` and `credit`; `image` now also shows for `pinned`. `record-roster` gains `logos[].name` (set in type when the logo has no image), `logos[].note`, `namesLabel` (the accessible name when every logo is a name, which then renders as a `ul`) and `quote` (`label`, `text`, `cite`, one figure after the grid). `record-roster` also gains an optional `heading`: one `h2[data-record-heading]` in the heading lane beside the kicker (accent runs render as in the other dossier headings). Unset, blank or whitespace renders nothing and every stored document is byte-identical (the golden suite proves it). A site that supplied the line as a ledger row can move it to the field.

  • 16eb14c: `phase-ledger` gains a `pinned` variant, and `record-roster` can set client names in type and carry one quote. Both are opt-in; stored documents and defaults render byte-identical. `pinned` is a numbered list of phases that sticks beside one 3:2 plate per phase (from 1024px), each plate captioned with a label line ("Phase 1 · Brief", from the new `phaseLabel` template), the phase's sentence, a `caption` and a `credit`. A small client island (`PhaseLedgerPinnedTracking`, one IntersectionObserver) marks the phase whose plate is in view with ink and a small accent square. Without JavaScript every phase shows; under reduced motion there is no easing but the active phase still changes. The four existing variants and the `panel` island are unchanged. From 1024px the plates are one track and plate 1 no longer keeps its phone span of two tracks, so every plate has the full column. New phase fields: `caption` and `credit`; `image` now also shows for `pinned`. `record-roster` gains `logos[].name` (set in type when the logo has no image), `logos[].note`, `namesLabel` (the accessible name when every logo is a name, which then renders as a `ul`) and `quote` (`label`, `text`, `cite`, one figure after the grid). `record-roster` also gains an optional `heading`: one `h2[data-record-heading]` in the heading lane beside the kicker (accent runs render as in the other dossier headings). Unset, blank or whitespace renders nothing and every stored document is byte-identical (the golden suite proves it). A site that supplied the line as a ledger row can move it to the field.
v0.17.0minor

d899880: `term-ledger` gains part hooks and an opt-in `termHeading`. Each row carries `data-term-row`; its term carries `data-term`; its body cell carries `data-term-body`. The body cell is a new `<div>` around the body, or the existing row cell when the row has a button. A row without a body renders its term only, as before. The new field `termHeading` (a checkbox, off by default) renders each term as an `<h3>` instead of a `<span>`, with the same class and hook, so the terms enter the page outline. With it off the markup is the same apart from the hooks (and the body wrapper). The variants, classes and styles are unchanged.

  • d899880: `term-ledger` gains part hooks and an opt-in `termHeading`. Each row carries `data-term-row`; its term carries `data-term`; its body cell carries `data-term-body`. The body cell is a new `<div>` around the body, or the existing row cell when the row has a button. A row without a body renders its term only, as before. The new field `termHeading` (a checkbox, off by default) renders each term as an `<h3>` instead of a `<span>`, with the same class and hook, so the terms enter the page outline. With it off the markup is the same apart from the hooks (and the body wrapper). The variants, classes and styles are unchanged.
v0.16.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.16.0minor

60ebb89: `practice-modes` gains a `ledger` variant, `fit-prose` an `order` option, and `dated-ledger` entries a `dateLabel`. - **`practice-modes` `ledger`**: the two modes become two paths compared row by row. Per mode it adds a `name`, a phone `tabLabel`, `inclusions[]`, a `duration`, `fit[]` chips, a `price` (`amount` and `note`), a `recommended` checkbox (at most one) and two doors: `primaryLink` (a filled button, usually a link out to a scheduler, with `newTab`) and `writeLink` (an outline button defaulting to "Write to me" and `#contact`, with a `prefill` of `field` and `value`). The block adds `rowLabels` (`included`, `duration`, `fit`, `price`) and a `tabsLabel`. Row labels sit in the left marginalia lane and the two paths are equal columns from the prose line to the content edge, each row subgridding the lane grid from 1024px; between 768px and 1023px a row lays out its own label column, and below 768px it stacks. On phones a small client island (`PracticeModesTabs`) shows an accessible tab list (arrow keys, Home, End) that opens on the recommended path; without JavaScript both paths show. Hooks: `data-block-variant="ledger"`, `data-ledger-row` (`head | included | duration | fit | price | doors`), `data-ledger-label`, `data-ledger-col` and `data-ledger-panel` (`a | b`), `data-mode-recommended`, `data-mode-kind`, `data-mode-name`, `data-mode-summary`, `data-mode-inclusions`, `data-mode-fit`, `data-mode-chip`, `data-mode-price`, `data-mode-price-note`, `data-mode-doors`, `data-mode-door`, `data-ledger-tabs`, `data-ledger-tab`. The write link carries `data-form-prefill="<field>=<value>"` when a prefill is set. An optional `deck` (one sentence, shown only on this variant) prints under the heading as `data-block-deck`; blank prints nothing. The default styling is theme-neutral. `practice-modes` now lists `default` and `ledger` as variants; content saved before renders as `default`, byte for byte. - **`fit-prose` `order`** (`not-first` by default, or `fit-first`): sets which paragraph comes first in the markup. The root carries `data-fit-order` when the option is stored; content saved before has no attribute and renders as before. - **`dated-ledger` `entries[].dateLabel`**: text shown in place of the date, such as "Now". It keeps the numeral's hook (`data-ledger-year` or `data-ledger-day`) and adds `data-date-label`; the `<time>` element is omitted when the entry has no date. Entries without a label render as before. The entry photo keeps its existing `data-ledger-photo` hook. - **`practice-modes` `ledger` price note**: a note that opens with punctuation (`,` `.` `;` `:`) is set straight after the amount with no space, so "$2,400" and ", or three payments of $800" read as one line. A note that opens with a letter keeps the space, and the markup for it is unchanged.

  • 60ebb89: `practice-modes` gains a `ledger` variant, `fit-prose` an `order` option, and `dated-ledger` entries a `dateLabel`. - **`practice-modes` `ledger`**: the two modes become two paths compared row by row. Per mode it adds a `name`, a phone `tabLabel`, `inclusions[]`, a `duration`, `fit[]` chips, a `price` (`amount` and `note`), a `recommended` checkbox (at most one) and two doors: `primaryLink` (a filled button, usually a link out to a scheduler, with `newTab`) and `writeLink` (an outline button defaulting to "Write to me" and `#contact`, with a `prefill` of `field` and `value`). The block adds `rowLabels` (`included`, `duration`, `fit`, `price`) and a `tabsLabel`. Row labels sit in the left marginalia lane and the two paths are equal columns from the prose line to the content edge, each row subgridding the lane grid from 1024px; between 768px and 1023px a row lays out its own label column, and below 768px it stacks. On phones a small client island (`PracticeModesTabs`) shows an accessible tab list (arrow keys, Home, End) that opens on the recommended path; without JavaScript both paths show. Hooks: `data-block-variant="ledger"`, `data-ledger-row` (`head | included | duration | fit | price | doors`), `data-ledger-label`, `data-ledger-col` and `data-ledger-panel` (`a | b`), `data-mode-recommended`, `data-mode-kind`, `data-mode-name`, `data-mode-summary`, `data-mode-inclusions`, `data-mode-fit`, `data-mode-chip`, `data-mode-price`, `data-mode-price-note`, `data-mode-doors`, `data-mode-door`, `data-ledger-tabs`, `data-ledger-tab`. The write link carries `data-form-prefill="<field>=<value>"` when a prefill is set. An optional `deck` (one sentence, shown only on this variant) prints under the heading as `data-block-deck`; blank prints nothing. The default styling is theme-neutral. `practice-modes` now lists `default` and `ledger` as variants; content saved before renders as `default`, byte for byte. - **`fit-prose` `order`** (`not-first` by default, or `fit-first`): sets which paragraph comes first in the markup. The root carries `data-fit-order` when the option is stored; content saved before has no attribute and renders as before. - **`dated-ledger` `entries[].dateLabel`**: text shown in place of the date, such as "Now". It keeps the numeral's hook (`data-ledger-year` or `data-ledger-day`) and adds `data-date-label`; the `<time>` element is omitted when the entry has no date. Entries without a label render as before. The entry photo keeps its existing `data-ledger-photo` hook. - **`practice-modes` `ledger` price note**: a note that opens with punctuation (`,` `.` `;` `:`) is set straight after the amount with no space, so "$2,400" and ", or three payments of $800" read as one line. A note that opens with a letter keeps the space, and the markup for it is unchanged.
v0.15.0minor

4ee0823: `comparison-table` cells can take an `emphasis` tone that sets one value in accent ink. The ink is the new `--tome-comparison-emphasis-ink` token (falling back to the accent text color), read by a rule that wins over the table's own cell color, so a theme sets the token and needs no selector of its own. Toned cells and their phone-card values now carry `data-cell-tone="affirm" | "absent" | "emphasis"`; cells with no tone render as before.

  • 4ee0823: `comparison-table` cells can take an `emphasis` tone that sets one value in accent ink. The ink is the new `--tome-comparison-emphasis-ink` token (falling back to the accent text color), read by a rule that wins over the table's own cell color, so a theme sets the token and needs no selector of its own. Toned cells and their phone-card values now carry `data-cell-tone="affirm" | "absent" | "emphasis"`; cells with no tone render as before.
v0.14.0minor

0435b77: `dated-ledger`: no stray separator under a year, and the chip can move into the date line. - With `datePrecision: 'year'` in `rows`, from 640px the year numeral takes its own line, so the line under it held only the reference, opened by the `·` separator ("· No. 1"). The separator is hidden there. Day precision, and every other layout, is unchanged. - New option `chipPlacement` (`body` by default, or `dateline`). With `dateline`, each entry's chip leaves the body and closes the date line on a line of its own, under the date and reference (`[data-ledger-date] [data-ledger-chip]`), so in `rows` it sits in the left margin under "No. 1". The root carries `data-ledger-chip-placement="dateline"`, and the gap above the chip is `--tome-dossier-ledger-dateline-chip-gap` (default `0.5rem`). Unset or `body` renders exactly as before.

  • 0435b77: `dated-ledger`: no stray separator under a year, and the chip can move into the date line. - With `datePrecision: 'year'` in `rows`, from 640px the year numeral takes its own line, so the line under it held only the reference, opened by the `·` separator ("· No. 1"). The separator is hidden there. Day precision, and every other layout, is unchanged. - New option `chipPlacement` (`body` by default, or `dateline`). With `dateline`, each entry's chip leaves the body and closes the date line on a line of its own, under the date and reference (`[data-ledger-date] [data-ledger-chip]`), so in `rows` it sits in the left margin under "No. 1". The root carries `data-ledger-chip-placement="dateline"`, and the gap above the chip is `--tome-dossier-ledger-dateline-chip-gap` (default `0.5rem`). Unset or `body` renders exactly as before.
  • e3aadfe: Theme hooks on links reach the DOM. - `@wabbit/tome-blocks-core`: `LinkProps` declares `data-*` and `aria-*` attributes, and the default anchor (`NoopLink`, used by `TomeLink` / `resolveLink` when no link adapter is registered) forwards them. It forwarded only `href`, `className`, `style`, `id`, `target` and `rel`, so every hook a pack put on a link (`data-section-label-link`, `data-panel-cta` and others) was dropped. Links without such attributes render exactly as before, and props that are neither `data-*` nor `aria-*` still stay off the anchor. A registered link adapter receives the attributes too: spread the rest of your `Link`'s props onto its anchor to keep them. - `@wabbit/tome-blocks-dossier-pack`: each `zone-directory` cell (the link anchor) carries `data-zone-cell`.
v0.13.0minor

6928b7f: Phase Ledger gains `index` and `panel` variants, and Dated Ledger gains year dates, compliance labels, a note and a compact density. - **Phase Ledger `index`**: a numbered `<ol>` of ruled rows (number, title, body, and a byline column with the outcome and an optional link line). Each phase's new `link` (`label`, `href`) covers the whole row. Hooks: `data-phase-index`, `data-phase-row`, `data-phase-link`; the number keeps `data-phase-num` so a theme can set a mark before it. - **Phase Ledger `panel`**: one large media panel above a step bar. Phases gain optional `image` and `meta`. Without JavaScript every step is listed in full; once hydrated the step bar is an ARIA tablist (click, arrow keys, Home and End), and at 768px wide and 600px tall the steps also follow the scroll along a pinned runway. Phones and short windows are never pinned. Under reduced motion the jump to a step is instant and nothing crossfades. The island root carries `data-tome-motion="self"`. Hooks: `data-phase-panel`, `data-phase-slide`, `data-phase-media`, `data-phase-meta`, `data-phase-tabs`, `data-phase-tab` (its label `data-phase-num`, its name `data-phase-title`), `data-phase-active`, plus the root's `data-panel-state`, `data-panel-ready` and `data-panel-follow`. - **Dated Ledger**: `datePrecision: 'year'` makes the year the large numeral (`data-date-precision`, `data-ledger-year`); `density: 'compact'` tightens the rows for short entries such as awards (`data-ledger-density`); each entry's `complianceLabel` closes the entry (`data-compliance-label`); a block `note` follows the entries (`data-ledger-note`, reveal group `note`). - **Dated Ledger label lane:** from 1024px a theme can redraw the `rows` grid of a labeled ledger to move the compliance label (for example into a right-hand lane) with `--tome-dossier-ledger-label-areas` / `--tome-dossier-ledger-label-cols` (entries without a photo) and `--tome-dossier-ledger-label-media-areas` / `--tome-dossier-ledger-label-media-cols` (with a photo). Each falls back to the current layout, and the column tokens apply only at the regular density, so computed styles are unchanged when they are unset. - **Zone Directory**: part hooks, attributes only: `data-zone-board` (the board; each cell is its direct child), `data-zone-num`, `data-zone-title`, `data-zone-description` and `data-zone-link`. Neither Zone Directory nor the Phase Ledger index prints a § itself; a theme sets one with `::before`. The new phase fields show in the admin only for their variant. Existing content, the `default` and `rail` variants, and a dated ledger with the options unset (or at their defaults) render exactly as before.

  • 6928b7f: Phase Ledger gains `index` and `panel` variants, and Dated Ledger gains year dates, compliance labels, a note and a compact density. - **Phase Ledger `index`**: a numbered `<ol>` of ruled rows (number, title, body, and a byline column with the outcome and an optional link line). Each phase's new `link` (`label`, `href`) covers the whole row. Hooks: `data-phase-index`, `data-phase-row`, `data-phase-link`; the number keeps `data-phase-num` so a theme can set a mark before it. - **Phase Ledger `panel`**: one large media panel above a step bar. Phases gain optional `image` and `meta`. Without JavaScript every step is listed in full; once hydrated the step bar is an ARIA tablist (click, arrow keys, Home and End), and at 768px wide and 600px tall the steps also follow the scroll along a pinned runway. Phones and short windows are never pinned. Under reduced motion the jump to a step is instant and nothing crossfades. The island root carries `data-tome-motion="self"`. Hooks: `data-phase-panel`, `data-phase-slide`, `data-phase-media`, `data-phase-meta`, `data-phase-tabs`, `data-phase-tab` (its label `data-phase-num`, its name `data-phase-title`), `data-phase-active`, plus the root's `data-panel-state`, `data-panel-ready` and `data-panel-follow`. - **Dated Ledger**: `datePrecision: 'year'` makes the year the large numeral (`data-date-precision`, `data-ledger-year`); `density: 'compact'` tightens the rows for short entries such as awards (`data-ledger-density`); each entry's `complianceLabel` closes the entry (`data-compliance-label`); a block `note` follows the entries (`data-ledger-note`, reveal group `note`). - **Dated Ledger label lane:** from 1024px a theme can redraw the `rows` grid of a labeled ledger to move the compliance label (for example into a right-hand lane) with `--tome-dossier-ledger-label-areas` / `--tome-dossier-ledger-label-cols` (entries without a photo) and `--tome-dossier-ledger-label-media-areas` / `--tome-dossier-ledger-label-media-cols` (with a photo). Each falls back to the current layout, and the column tokens apply only at the regular density, so computed styles are unchanged when they are unset. - **Zone Directory**: part hooks, attributes only: `data-zone-board` (the board; each cell is its direct child), `data-zone-num`, `data-zone-title`, `data-zone-description` and `data-zone-link`. Neither Zone Directory nor the Phase Ledger index prints a § itself; a theme sets one with `::before`. The new phase fields show in the admin only for their variant. Existing content, the `default` and `rail` variants, and a dated ledger with the options unset (or at their defaults) render exactly as before.
v0.12.1patch

d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.

  • d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.
v0.12.0minor

8960ee1: Phase Ledger outcome hooks, an Info Panel card kicker, and two-column margin facts on phones. - **Phase Ledger** (dossier): each phase's outcome line carries `data-phase-outcome`, and its italic and weight read `--tome-dossier-phase-outcome-style` (default `italic`) and `--tome-dossier-phase-outcome-weight` (default `330`), so a theme can restyle the outcome without overriding the declarations. The look is unchanged when neither token is set. - **Info Panel** (editorial): the sticky panel card gains an optional `panelKicker`, a small label rendered first in the card, above the person (hook `data-panel-kicker`). It is read only with `stickyPanel` on, and a kicker alone is enough to render the card. The person's `name` may now be a full lead sentence ("Marisol Vance leads roof replacements."), with `role` as the sentences that follow. Both wrap beside the initials with no clamp or truncation, and the admin label and descriptions say so. Set `initials` when the name is a sentence. - **Editorial Spread** (editorial): margin facts (`rail.statCallouts` that carry a note) sit in two columns on phones (under 640px) instead of one long column. An odd last fact sits alone in the first column. Tablets keep the auto-fill columns, and the desktop rail is unchanged. Existing content renders as before.

  • 8960ee1: Phase Ledger outcome hooks, an Info Panel card kicker, and two-column margin facts on phones. - **Phase Ledger** (dossier): each phase's outcome line carries `data-phase-outcome`, and its italic and weight read `--tome-dossier-phase-outcome-style` (default `italic`) and `--tome-dossier-phase-outcome-weight` (default `330`), so a theme can restyle the outcome without overriding the declarations. The look is unchanged when neither token is set. - **Info Panel** (editorial): the sticky panel card gains an optional `panelKicker`, a small label rendered first in the card, above the person (hook `data-panel-kicker`). It is read only with `stickyPanel` on, and a kicker alone is enough to render the card. The person's `name` may now be a full lead sentence ("Marisol Vance leads roof replacements."), with `role` as the sentences that follow. Both wrap beside the initials with no clamp or truncation, and the admin label and descriptions say so. Set `initials` when the name is a sentence. - **Editorial Spread** (editorial): margin facts (`rail.statCallouts` that carry a note) sit in two columns on phones (under 640px) instead of one long column. An odd last fact sits alone in the first column. Tablets keep the auto-fill columns, and the desktop rail is unchanged. Existing content renders as before.
  • fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.
v0.11.0minor

43d7aa0: Lab Notes, Fit List and Cost Ledger: new options, line breaks, contrast and an entrance. - **Lab Notes** gains `showNumerals` (default on). Off drops the E.01… numerals and their grid column at every width: the kicker sits above the body on narrow screens, and from 900px the body lines up with the Handoff Body. The handoff body now sets at body size (`--tome-text-md`) on the prose measure (`--tome-prose-max-width`, 62ch when unset) instead of the small size at 62ch. Entry and handoff text wrap `pretty`; the headline wraps `balance`. - **Fit List**: a new line in the Anchored close renders as a line break (`*em*` accents still parse). The muted skip column and its label are raised from 55% to 70% ink for AA contrast. List items wrap `pretty`; the heading and close wrap `balance`. - **Cost Ledger** gains `compoundsStyle` (`label` default, or `prose`). Prose sets the third column in the body face at `--tome-text-md`, sentence case, in the body color; the lit row keeps its inverted colors. A new line in the coda renders as a line break. The muted column labels and third column are raised from 55% to 70% ink (the lit row's third column from 60% to 70%). The heading wraps `balance`; the intro and coda wrap `pretty`. - **Entrance motion**: the three blocks now enter part by part, with no markup hidden up front. Cost Ledger rows post one by one from the left (170ms apart) with the lit row landing last; Lab Notes prints each entry in order (70ms apart) and the handoff after; Fit List's build column arrives from the left and its skip column from the right (90ms apart). Content is visible with JavaScript off, parts already on screen at load are left alone, and reduced motion is read when the page loads, when it changes, and when each group enters. The Cost Ledger rows and the Fit List columns no longer take part in the opt-in group reveal (`context.dossierReveal`); the kicker, heading, intro, column strip and close still do. Existing content renders as before apart from the contrast lift and the Lab Notes handoff size.

  • 43d7aa0: Lab Notes, Fit List and Cost Ledger: new options, line breaks, contrast and an entrance. - **Lab Notes** gains `showNumerals` (default on). Off drops the E.01… numerals and their grid column at every width: the kicker sits above the body on narrow screens, and from 900px the body lines up with the Handoff Body. The handoff body now sets at body size (`--tome-text-md`) on the prose measure (`--tome-prose-max-width`, 62ch when unset) instead of the small size at 62ch. Entry and handoff text wrap `pretty`; the headline wraps `balance`. - **Fit List**: a new line in the Anchored close renders as a line break (`*em*` accents still parse). The muted skip column and its label are raised from 55% to 70% ink for AA contrast. List items wrap `pretty`; the heading and close wrap `balance`. - **Cost Ledger** gains `compoundsStyle` (`label` default, or `prose`). Prose sets the third column in the body face at `--tome-text-md`, sentence case, in the body color; the lit row keeps its inverted colors. A new line in the coda renders as a line break. The muted column labels and third column are raised from 55% to 70% ink (the lit row's third column from 60% to 70%). The heading wraps `balance`; the intro and coda wrap `pretty`. - **Entrance motion**: the three blocks now enter part by part, with no markup hidden up front. Cost Ledger rows post one by one from the left (170ms apart) with the lit row landing last; Lab Notes prints each entry in order (70ms apart) and the handoff after; Fit List's build column arrives from the left and its skip column from the right (90ms apart). Content is visible with JavaScript off, parts already on screen at load are left alone, and reduced motion is read when the page loads, when it changes, and when each group enters. The Cost Ledger rows and the Fit List columns no longer take part in the opt-in group reveal (`context.dossierReveal`); the kicker, heading, intro, column strip and close still do. Existing content renders as before apart from the contrast lift and the Lab Notes handoff size.
  • 4fe4ca3: Dossier blocks now stop their scroll animations when reduced motion is switched on or the block unmounts. Previously the tweens and scroll triggers already built kept running (the evidence plate's ink fill kept rewriting its background on scroll) and leaked on unmount. The cleanup works with the existing `@wabbit/tome-blocks-house` peer range.
v0.10.1patch

dd76bb4: The four remaining motion islands (`case-file-row`, `evidence-plate`, `receipts-trio`, `record-roster`) now follow `prefers-reduced-motion` live, as `proof-plates` and the opt-in reveal already do. Each used a one-shot read at mount, so a page that loaded under reduced motion and was then switched to normal motion kept its pending parts hidden by the stylesheet with no effect left to reveal them. They now use the pack's reactive `useReducedMotion` as an effect dependency: reduced to normal plays the reveal, and normal to reduced shows every part in its finished state (the evidence plate's ink fill also drops its scrubbed position). Behavior is unchanged when the preference never changes.

  • dd76bb4: The four remaining motion islands (`case-file-row`, `evidence-plate`, `receipts-trio`, `record-roster`) now follow `prefers-reduced-motion` live, as `proof-plates` and the opt-in reveal already do. Each used a one-shot read at mount, so a page that loaded under reduced motion and was then switched to normal motion kept its pending parts hidden by the stylesheet with no effect left to reveal them. They now use the pack's reactive `useReducedMotion` as an effect dependency: reduced to normal plays the reveal, and normal to reduced shows every part in its finished state (the evidence plate's ink fill also drops its scrubbed position). Behavior is unchanged when the preference never changes.
v0.10.0minor

3e42d40: Proof Plates and Term Ledger gain opt-in buttons and a text-edge option; defaults render as before. Proof Plates: a plate's `linkStyle: 'button'` (default `inline`) sets its link as a button at the plate's foot, lined up across plates of different heights, with `linkAppearance` `outline` (default) or `default` (accent fill). Term Ledger: a row's `cta` and the block's `introCta` (`{ label, url, appearance? }`) render as buttons, and `introEdge: 'rows'` lines the intro and close up on the row-body text edge from 721px. The buttons share one stylesheet (44px targets, stacked full width at 640px and below, a trailing arrow bound to the last word). Fixed: a page that loaded under reduced motion and was then switched to normal motion left its pending reveal parts invisible; the reveal islands now follow the preference live and play the reveal on that switch. Term Ledger row bodies are also capped by the host `--tome-prose-max-width` when one is set (68ch/74ch remain the fallbacks).

  • 3e42d40: Proof Plates and Term Ledger gain opt-in buttons and a text-edge option; defaults render as before. Proof Plates: a plate's `linkStyle: 'button'` (default `inline`) sets its link as a button at the plate's foot, lined up across plates of different heights, with `linkAppearance` `outline` (default) or `default` (accent fill). Term Ledger: a row's `cta` and the block's `introCta` (`{ label, url, appearance? }`) render as buttons, and `introEdge: 'rows'` lines the intro and close up on the row-body text edge from 721px. The buttons share one stylesheet (44px targets, stacked full width at 640px and below, a trailing arrow bound to the last word). Fixed: a page that loaded under reduced motion and was then switched to normal motion left its pending reveal parts invisible; the reveal islands now follow the preference live and play the reveal on that switch. Term Ledger row bodies are also capped by the host `--tome-prose-max-width` when one is set (68ch/74ch remain the fallbacks).
v0.9.0minor

245fa47: CompareLedger gains three optional settings. Existing blocks render exactly as before. - `tone` (`contrast` by default, or `equal`): `equal` sets every option at full ink with no strike-through, and left-aligns the display values and the row cells, for two honest options side by side (for example "what the first year costs"). The first column's label takes the same accent color as the other. - `rows[].label`: a small mono label that spans the row above its cells. A row with only a label still renders. - `closeLink` (`{ label, url }`): a link added at the end of the close statement, underlined in the surrounding text color. It renders only when both the label and the URL are set. Prose cells keep their existing line-length cap in both tones.

  • 245fa47: CompareLedger gains three optional settings. Existing blocks render exactly as before. - `tone` (`contrast` by default, or `equal`): `equal` sets every option at full ink with no strike-through, and left-aligns the display values and the row cells, for two honest options side by side (for example "what the first year costs"). The first column's label takes the same accent color as the other. - `rows[].label`: a small mono label that spans the row above its cells. A row with only a label still renders. - `closeLink` (`{ label, url }`): a link added at the end of the close statement, underlined in the surrounding text color. It renders only when both the label and the URL are set. Prose cells keep their existing line-length cap in both tones.
  • 245fa47: CompareLedger's heading no longer pushes the page into horizontal scroll on a 320px screen. A single long word at hero size could be wider than a narrow column. The heading now sets `overflow-wrap: break-word`, so such a word breaks onto the next line only when it would otherwise overflow. Layouts where every word fits are unchanged.
v0.8.0minor

432d69b: Running copy reads the platform body token. Thirteen dossier blocks set paragraph-level copy at the small step (`--tome-text-sm` / `--tome-type-size-sm`), an `xs` note size, a heading step (`--tome-text-h5`, `--tome-type-size-h5`) or literals (`1.02rem`, `0.92rem`). Their running copy now reads `--tome-text-body`, the platform's body size, so a consumer's body token sets it everywhere: - comparisonTable cells and footnote, recordRoster row descriptions, specSheet note, zoneDirectory cell descriptions, caseFileGrid tile briefs (both treatments), specPlate cell notes, fitList items: small step → body - evidenceSheet ledger row notes: xs → body - practiceModes mode copy (1.02rem), caseFileRow cell lines (0.92rem): literal → body - proofPlates plate body: `--tome-type-size-body` (undefined on the platform) → body - termLedger, proofPlates and wallRows closing paragraphs (and proofPlates' close card): heading step → body Visible change for consumers on the default scale: card and table copy moves from 14px to 16px, and closing paragraphs from 18–20px to 16px. Labels, values, captions and display variants are unchanged.

  • 432d69b: Running copy reads the platform body token. Thirteen dossier blocks set paragraph-level copy at the small step (`--tome-text-sm` / `--tome-type-size-sm`), an `xs` note size, a heading step (`--tome-text-h5`, `--tome-type-size-h5`) or literals (`1.02rem`, `0.92rem`). Their running copy now reads `--tome-text-body`, the platform's body size, so a consumer's body token sets it everywhere: - comparisonTable cells and footnote, recordRoster row descriptions, specSheet note, zoneDirectory cell descriptions, caseFileGrid tile briefs (both treatments), specPlate cell notes, fitList items: small step → body - evidenceSheet ledger row notes: xs → body - practiceModes mode copy (1.02rem), caseFileRow cell lines (0.92rem): literal → body - proofPlates plate body: `--tome-type-size-body` (undefined on the platform) → body - termLedger, proofPlates and wallRows closing paragraphs (and proofPlates' close card): heading step → body Visible change for consumers on the default scale: card and table copy moves from 14px to 16px, and closing paragraphs from 18–20px to 16px. Labels, values, captions and display variants are unchanged.
v0.7.1patch

07e4773: Dossier lane kickers now carry a `data-lane-kicker` hook, and each `phase-ledger` step number carries `data-phase-num`. The hook is on the kicker text of every block that sets one (`dated-ledger`, `phase-ledger`, the cost, grants, term and other ledgers, `fit-list`, `fit-prose`, `practice-modes`, `proof-plates`, `receipts-trio`, `record-roster`, `wall-rows`, `zone-directory`, `case-file-row`), so a theme restyles them with one rule. Attributes only; nothing renders differently.

  • 07e4773: Dossier lane kickers now carry a `data-lane-kicker` hook, and each `phase-ledger` step number carries `data-phase-num`. The hook is on the kicker text of every block that sets one (`dated-ledger`, `phase-ledger`, the cost, grants, term and other ledgers, `fit-list`, `fit-prose`, `practice-modes`, `proof-plates`, `receipts-trio`, `record-roster`, `wall-rows`, `zone-directory`, `case-file-row`), so a theme restyles them with one rule. Attributes only; nothing renders differently.
v0.7.0minor

115bcfb: Add the `dated-ledger` block and a `rail` variant for `phase-ledger`. `dated-ledger` is a dated record of finished work: a kicker, heading and intro, then 1 to 12 entries, each led by a large day numeral with a month, year and reference line in tabular figures (`<time dateTime>`), then an optional photo, chip, title, result line and figure, with an optional per-entry link and a closing "more" link. Two variants: `columns` (default; up to four per row, one on phones) and `rows` (ruled rows, the date in the left lane and the figure in the right lane). Theme hooks are stable `data-ledger-*` attributes and `data-block-variant` on the root; it joins the opt-in scroll reveal (`shead`, `entries`, `more`). `phase-ledger` now declares `default` and `rail` variants. Content saved without a variant renders as `default`, unchanged apart from `data-variant` / `data-block-variant` on the root. `rail` sets the phases beside a progress rail that fills as the section scrolls and lights each phase's dot when reached: from 1024px as up to four columns under a horizontal rail filling left to right, below it as stacked rows beside a vertical rail on the left edge, driven by a small client island (`PhaseLedgerRailMotion`, no GSAP). Without JavaScript and under reduced motion the rail renders fully drawn with every dot on. Hooks: `data-phase-rail` (with `data-cols` and `data-rail-axis="x" | "y"`), `data-phase-rail-track`, `data-phase-rail-fill`, `data-phase-step` with `data-active`, `data-phase-rail-dot`, and the `--tome-dossier-rail-progress` property.

  • 115bcfb: Add the `dated-ledger` block and a `rail` variant for `phase-ledger`. `dated-ledger` is a dated record of finished work: a kicker, heading and intro, then 1 to 12 entries, each led by a large day numeral with a month, year and reference line in tabular figures (`<time dateTime>`), then an optional photo, chip, title, result line and figure, with an optional per-entry link and a closing "more" link. Two variants: `columns` (default; up to four per row, one on phones) and `rows` (ruled rows, the date in the left lane and the figure in the right lane). Theme hooks are stable `data-ledger-*` attributes and `data-block-variant` on the root; it joins the opt-in scroll reveal (`shead`, `entries`, `more`). `phase-ledger` now declares `default` and `rail` variants. Content saved without a variant renders as `default`, unchanged apart from `data-variant` / `data-block-variant` on the root. `rail` sets the phases beside a progress rail that fills as the section scrolls and lights each phase's dot when reached: from 1024px as up to four columns under a horizontal rail filling left to right, below it as stacked rows beside a vertical rail on the left edge, driven by a small client island (`PhaseLedgerRailMotion`, no GSAP). Without JavaScript and under reduced motion the rail renders fully drawn with every dot on. Hooks: `data-phase-rail` (with `data-cols` and `data-rail-axis="x" | "y"`), `data-phase-rail-track`, `data-phase-rail-fill`, `data-phase-step` with `data-active`, `data-phase-rail-dot`, and the `--tome-dossier-rail-progress` property.
v0.6.4patch

f876e5e: Compare Ledger's heading starts on the content edge. Visible change: from 1024px wide, the heading used to start at the reading column, leaving the left lane empty beside it (the block has no kicker to fill it). It now starts on the content edge, aligned with the column labels and the rule below it. Below 1024px nothing moves.

  • f876e5e: Compare Ledger's heading starts on the content edge. Visible change: from 1024px wide, the heading used to start at the reading column, leaving the left lane empty beside it (the block has no kicker to fill it). It now starts on the content edge, aligned with the column labels and the rule below it. Below 1024px nothing moves.
v0.6.3patch

b452c7b: Shift Rows gains a token for an empty thumbnail's outline, and its CTA becomes an inline-block. No change until a site sets the token: - `--tome-dossier-thumb-placeholder-border` (default `1px dashed color-mix(in oklch, currentColor 30%, transparent)`): the outline of an empty Shift Rows thumbnail slot, a `border` shorthand (`none` drops it). Visible change on a default render: - **Shift Rows' CTA** is an `inline-block` instead of an `inline-flex`. The label is plain text, so it renders and wraps the same; a label that wraps to two lines no longer leaves a line-box strut (about 1.5px) under the link's box, so the block ends that much sooner below a two-line CTA. A one-line CTA does not move. Ledger's CTA is unchanged.

  • b452c7b: Shift Rows gains a token for an empty thumbnail's outline, and its CTA becomes an inline-block. No change until a site sets the token: - `--tome-dossier-thumb-placeholder-border` (default `1px dashed color-mix(in oklch, currentColor 30%, transparent)`): the outline of an empty Shift Rows thumbnail slot, a `border` shorthand (`none` drops it). Visible change on a default render: - **Shift Rows' CTA** is an `inline-block` instead of an `inline-flex`. The label is plain text, so it renders and wraps the same; a label that wraps to two lines no longer leaves a line-box strut (about 1.5px) under the link's box, so the block ends that much sooner below a two-line CTA. A one-line CTA does not move. Ledger's CTA is unchanged.
v0.6.2patch

eee4d87: Ledger and Shift Rows CTAs read the house editorial CTA register, and Shift Rows gains tokens for its label column, section marker and empty thumbnails. **CTA register.** Both CTAs read `--tome-house-cta-editorial-family`, `-size`, `-weight`, `-tracking`, `-transform`, `-margin` and `-color` from `@wabbit/tome-blocks-house`, each falling back to the link's current value: the inherited sans, `--tome-text-sm`, 600, `--tome-house-tracking-cta-editorial`, uppercase, a `--tome-space-2xl` top margin and the accent ink (Shift Rows: `--tome-color-accent-text`; Ledger: the band's `--blk-accent-ink` first). On a grid root (a site that subgrids the block onto its page grid) each CTA takes `--tome-house-cta-cols` and starts its row instead of stretching across it; on Shift Rows' stock block root this is inert. No change until a site sets the token: - `--tome-dossier-row-label-pad-start-wide` (default `var(--tome-space-md)`): Shift Rows' label column top padding from 64rem. The design source's value is `var(--tome-space-lg)`, one step more than the bottom, which sets the label a little lower beside the panels. - `--tome-dossier-marker-tracking` (default `var(--tome-house-tracking-mono)`): Shift Rows' section marker tracking, so a site that retracks the mono labels can hold the marker. - `--tome-dossier-thumb-placeholder-pad` (default `var(--tome-space-sm)`) and `--tome-dossier-thumb-placeholder-ink` (default `color-mix(in oklch, currentColor 55%, transparent)`): an empty Shift Rows thumbnail slot's padding and label ink. Visible change on a default render: - **Ledger's CTA** no longer stretches across the block: the link's box (its clickable and focus area) now ends at its label, as Shift Rows' already did. The label itself does not move.

  • eee4d87: Ledger and Shift Rows CTAs read the house editorial CTA register, and Shift Rows gains tokens for its label column, section marker and empty thumbnails. **CTA register.** Both CTAs read `--tome-house-cta-editorial-family`, `-size`, `-weight`, `-tracking`, `-transform`, `-margin` and `-color` from `@wabbit/tome-blocks-house`, each falling back to the link's current value: the inherited sans, `--tome-text-sm`, 600, `--tome-house-tracking-cta-editorial`, uppercase, a `--tome-space-2xl` top margin and the accent ink (Shift Rows: `--tome-color-accent-text`; Ledger: the band's `--blk-accent-ink` first). On a grid root (a site that subgrids the block onto its page grid) each CTA takes `--tome-house-cta-cols` and starts its row instead of stretching across it; on Shift Rows' stock block root this is inert. No change until a site sets the token: - `--tome-dossier-row-label-pad-start-wide` (default `var(--tome-space-md)`): Shift Rows' label column top padding from 64rem. The design source's value is `var(--tome-space-lg)`, one step more than the bottom, which sets the label a little lower beside the panels. - `--tome-dossier-marker-tracking` (default `var(--tome-house-tracking-mono)`): Shift Rows' section marker tracking, so a site that retracks the mono labels can hold the marker. - `--tome-dossier-thumb-placeholder-pad` (default `var(--tome-space-sm)`) and `--tome-dossier-thumb-placeholder-ink` (default `color-mix(in oklch, currentColor 55%, transparent)`): an empty Shift Rows thumbnail slot's padding and label ink. Visible change on a default render: - **Ledger's CTA** no longer stretches across the block: the link's box (its clickable and focus area) now ends at its label, as Shift Rows' already did. The label itself does not move.
v0.6.1patch

7c94229: Term Ledger gains an opt-in figure layout for the head face and a leading token for the spec term. No change to any existing render until an editor sets the option or a site sets the token: - **Term Ledger `figureLayout`** (new select field, default `split`). With a supporting figure, `display` gives the head face the figure layout the display face already uses: the intro in the reading column, the ledger stopping at the end of the prose lane with an 11 to 15rem term column, heavier terms, row bodies held to 58ch and the tilted figure plate (with `--tome-dossier-figure-shadow`) filling the rest of the row. The heading, band and close stay the head face's. Without a figure, and on the display face, the option does nothing. Left at `split`, the head face keeps its own rows beside a 200 to 320px figure. - **Term Ledger spec term leading.** The `spec` and `spec-measure` terms read `--tome-dossier-spec-term-leading`, falling back to 1.1, the leading they already had. Schema: `figureLayout` is a new column on the Term Ledger block. On a SQL adapter (Postgres, SQLite), generate and run a migration after upgrading; existing rows take `split`. Documentation: the README notes that Phase Ledger's row titles take the page's heading tracking, so a site retracking the block heading alone should set `--tome-house-heading-tracking` rather than re-point `--tome-type-tracking-tight`.

  • 7c94229: Term Ledger gains an opt-in figure layout for the head face and a leading token for the spec term. No change to any existing render until an editor sets the option or a site sets the token: - **Term Ledger `figureLayout`** (new select field, default `split`). With a supporting figure, `display` gives the head face the figure layout the display face already uses: the intro in the reading column, the ledger stopping at the end of the prose lane with an 11 to 15rem term column, heavier terms, row bodies held to 58ch and the tilted figure plate (with `--tome-dossier-figure-shadow`) filling the rest of the row. The heading, band and close stay the head face's. Without a figure, and on the display face, the option does nothing. Left at `split`, the head face keeps its own rows beside a 200 to 320px figure. - **Term Ledger spec term leading.** The `spec` and `spec-measure` terms read `--tome-dossier-spec-term-leading`, falling back to 1.1, the leading they already had. Schema: `figureLayout` is a new column on the Term Ledger block. On a SQL adapter (Postgres, SQLite), generate and run a migration after upgrading; existing rows take `split`. Documentation: the README notes that Phase Ledger's row titles take the page's heading tracking, so a site retracking the block heading alone should set `--tome-house-heading-tracking` rather than re-point `--tome-type-tracking-tight`.
v0.6.0minor

59d51da: **BREAKING:** Shift Rows and Ledger switch their row grid on their own width, Shift Rows' after panel reads the band's emphasis pair, and the `@wabbit/tome-blocks-house` peer floor rises to 0.8.4. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.4 or later (the peer range is now `>=0.8.4 <1.0.0`; the pack's fill-mode media options now come from that package's `fillMediaOptions`). No content or config changes. Visible changes on a default render: - **Shift Rows and Ledger, layout.** Shift Rows puts label, before, arrow and after side by side once its row list is 51rem wide, and Ledger shows its four columns once its table is 58rem wide. Both used to switch on the window width (from 816px and 928px). Placed in a column narrower than the window, they now stack until the block itself has room. The thresholds are container queries, so `rem` is the page's root font size: on a page whose root is not 16px they move with it. - **Shift Rows, arrow.** The stacked arrow's vertical padding applies below a 1024px window (it was below 816px). - **Shift Rows, thumbnails.** A thumbnail image no longer has a dashed border; it has a solid one only when its Image Display border is on. An empty thumbnail slot keeps its dashed outline. Thumbnails pass the media adapter `imgClassName`, `fill: true` and `sizes: '96px'`, so an adapter that wraps its image fills the 96px box with the Image Display fit and focus; the built-in adapter renders the same `<img>` with a second class and a `sizes` attribute. - **Shift Rows, editor Text Size.** The chrome text size now sizes the intro and the before and after bodies, and no longer the heading (which used to take the body size when an editor picked one). With no text size picked nothing changes. - **Shift Rows on a stored band.** The after panel's text takes the band's ink instead of the theme foreground, so it stays readable on a dark band. The row labels, arrows, the after panel's border and its AFTER tag read the band's accent ink first: on solid-dark and inverse bands (and site bands that carry the companion) they take the band's accent instead of the theme's. On the other built-in bands they render as before. No other change until a site sets a token or registers a band value: - Shift Rows' after panel reads `--blk-emph-bg` (fill) and `--blk-emph-fg` (text and AFTER tag), the emphasis pair from `@wabbit/tome-blocks-house`. No built-in band sets them, so the panel stays a faint accent tint; a site band registered with `emph` and `onEmph` turns it into a solid card. - Shift Rows' before and after headlines read `--tome-dossier-before-headline-size` and `--tome-dossier-after-headline-size` (`--tome-type-size-xxl` when unset). - Ledger's cost cells read `--tome-dossier-cost-size` (`--tome-type-size-xl`) and `--tome-dossier-debit-ink` (`--tome-color-error-text`); its closing note reads `--tome-dossier-closing-size` (`--tome-type-size-xxl`); its closing panel reads `--tome-dossier-closing-bg` and `--tome-dossier-closing-fg` (tome-ui's solid-dark surface and its ink). Declared on the block's root, the panel tokens can mix from the band's `--blk-bg` and `--blk-fg`.

  • 59d51da: **BREAKING:** Shift Rows and Ledger switch their row grid on their own width, Shift Rows' after panel reads the band's emphasis pair, and the `@wabbit/tome-blocks-house` peer floor rises to 0.8.4. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.4 or later (the peer range is now `>=0.8.4 <1.0.0`; the pack's fill-mode media options now come from that package's `fillMediaOptions`). No content or config changes. Visible changes on a default render: - **Shift Rows and Ledger, layout.** Shift Rows puts label, before, arrow and after side by side once its row list is 51rem wide, and Ledger shows its four columns once its table is 58rem wide. Both used to switch on the window width (from 816px and 928px). Placed in a column narrower than the window, they now stack until the block itself has room. The thresholds are container queries, so `rem` is the page's root font size: on a page whose root is not 16px they move with it. - **Shift Rows, arrow.** The stacked arrow's vertical padding applies below a 1024px window (it was below 816px). - **Shift Rows, thumbnails.** A thumbnail image no longer has a dashed border; it has a solid one only when its Image Display border is on. An empty thumbnail slot keeps its dashed outline. Thumbnails pass the media adapter `imgClassName`, `fill: true` and `sizes: '96px'`, so an adapter that wraps its image fills the 96px box with the Image Display fit and focus; the built-in adapter renders the same `<img>` with a second class and a `sizes` attribute. - **Shift Rows, editor Text Size.** The chrome text size now sizes the intro and the before and after bodies, and no longer the heading (which used to take the body size when an editor picked one). With no text size picked nothing changes. - **Shift Rows on a stored band.** The after panel's text takes the band's ink instead of the theme foreground, so it stays readable on a dark band. The row labels, arrows, the after panel's border and its AFTER tag read the band's accent ink first: on solid-dark and inverse bands (and site bands that carry the companion) they take the band's accent instead of the theme's. On the other built-in bands they render as before. No other change until a site sets a token or registers a band value: - Shift Rows' after panel reads `--blk-emph-bg` (fill) and `--blk-emph-fg` (text and AFTER tag), the emphasis pair from `@wabbit/tome-blocks-house`. No built-in band sets them, so the panel stays a faint accent tint; a site band registered with `emph` and `onEmph` turns it into a solid card. - Shift Rows' before and after headlines read `--tome-dossier-before-headline-size` and `--tome-dossier-after-headline-size` (`--tome-type-size-xxl` when unset). - Ledger's cost cells read `--tome-dossier-cost-size` (`--tome-type-size-xl`) and `--tome-dossier-debit-ink` (`--tome-color-error-text`); its closing note reads `--tome-dossier-closing-size` (`--tome-type-size-xxl`); its closing panel reads `--tome-dossier-closing-bg` and `--tome-dossier-closing-fg` (tome-ui's solid-dark surface and its ink). Declared on the block's root, the panel tokens can mix from the band's `--blk-bg` and `--blk-fg`.
  • ecd624a: Rich-text intros no longer center under a host adapter's container class, Spec Plate's lede takes the site's prose measure, and Cost Ledger gets two tokens. Visible change only where a site's rich-text adapter adds a centering container class (auto inline margins, `max-width: 100%`) to the element it renders: the intros of Wall Rows, Term Ledger (both faces) and Phase Ledger used to be centered in their slot, or uncapped, depending on stylesheet order. They now start on the slot's start line and hold their measure, through a two-class rule that sets `margin-inline: 0` and the same cap. With the built-in adapter nothing changes. No other change until a site sets a token: - Spec Plate's lede holds `min(62ch, var(--tome-prose-max-width, 62ch))`, so a prose measure narrower than 62ch now caps it. Unset, it is 62ch as before, on both heading faces. - Cost Ledger's intro reads `--tome-dossier-ledger-intro-size`, falling back to `--tome-text-lg`, so the intro can be sized apart from the debit cells, which keep `--tome-text-lg`. - Cost Ledger's line label in the lit row reads `--tome-dossier-ledger-lit-label-ink`, falling back to 80% of the plate's ink. It is a token rather than the band's accent ink because the lit plate always carries the solid-dark band, which always sets one; reading it would have recoloured every lit label.
v0.5.5patch

b3c65ba: Fixed: later rows of a block could appear before the block scrolled into view, then jump back and animate; they now stay hidden until their group enters. This affected the five blocks with their own entrance motion: Case File Row, Evidence Plate, Proof Plates, Receipts Trio and Record Roster. A timer started when the page loaded removed the hiding class from every part of a group after 900ms, while only the group's first part was held hidden by the animation itself. On a group still below the fold, the cells, stats, quotes, roster rows or plates after the first one showed early. Each part's hidden state is now set on the part itself before its group's animation is built, and cleared when that part finishes animating, so a settled part renders from its stylesheet alone. Reduced-motion and no-JavaScript rendering are unchanged, and the tilted flagged card in Proof Plates still settles on its tilt. The same five blocks now read `context.dossierReveal` for their timing, as the opt-in reveal does: `duration`, `stagger`, `ease`, `start` and `rise` each replace the block's own value when set. Without the key each block keeps its own timing: Case File Row, Evidence Plate and Receipts Trio take duration and stagger from the motion tokens, Record Roster and Proof Plates run 0.56s with an 80ms stagger, and all five start at `top 85%` with a 50px rise. Deliberate motion change: in Case File Row, Receipts Trio and the card layout of Proof Plates, the kicker and the heading beside it now reveal together as one step instead of one stagger apart (80ms with the default tokens), so the head enters as one element. Everything after the head moves as before.

  • b3c65ba: Fixed: later rows of a block could appear before the block scrolled into view, then jump back and animate; they now stay hidden until their group enters. This affected the five blocks with their own entrance motion: Case File Row, Evidence Plate, Proof Plates, Receipts Trio and Record Roster. A timer started when the page loaded removed the hiding class from every part of a group after 900ms, while only the group's first part was held hidden by the animation itself. On a group still below the fold, the cells, stats, quotes, roster rows or plates after the first one showed early. Each part's hidden state is now set on the part itself before its group's animation is built, and cleared when that part finishes animating, so a settled part renders from its stylesheet alone. Reduced-motion and no-JavaScript rendering are unchanged, and the tilted flagged card in Proof Plates still settles on its tilt. The same five blocks now read `context.dossierReveal` for their timing, as the opt-in reveal does: `duration`, `stagger`, `ease`, `start` and `rise` each replace the block's own value when set. Without the key each block keeps its own timing: Case File Row, Evidence Plate and Receipts Trio take duration and stagger from the motion tokens, Record Roster and Proof Plates run 0.56s with an 80ms stagger, and all five start at `top 85%` with a 50px rise. Deliberate motion change: in Case File Row, Receipts Trio and the card layout of Proof Plates, the kicker and the heading beside it now reveal together as one step instead of one stagger apart (80ms with the default tokens), so the head enters as one element. Everything after the head moves as before.
  • 9d30f92: Comparison tables show stacked row cards below 1024px, Term Ledger's display face gets its editorial layouts, and Case File Grid, Spec Plate and Lab Notes sit on the reading lanes. Visible changes on a default render: - **Comparison Table, below 1024px.** Each row shows as a stacked card (the row label as the head, every column's answer under a small mono column tag, the highlighted column tinted or filled) instead of a sideways-scrolling table. Answers stack one line each below 640px and sit side by side from 640px. The table stays in the page, visually hidden, for assistive technology. From 1024px nothing changes. - **Term Ledger with `headingFace: 'display'`.** Without a figure the block moves to the tinted surface band, puts the kick label in the left lane beside the heading, sets the intro in the reading column, stops the ledger at the end of the prose lane with an 11 to 15rem term column, smaller terms and row bodies held to 58ch, and closes with a ruled serif line across the content width. With a figure the intro takes the reading column, the ledger stops at the end of the prose lane and the tilted figure plate (now with a shadow) fills the rest of the row, with heavier terms. A Term Ledger that leaves `headingFace` unset renders exactly as before, figure included. - **Case File Grid, Spec Plate and Lab Notes.** They now render a bleeding lane grid with every part in the content column. On a page grid the band now paints the whole row instead of the content column; the content does not move. Without a page grid the content gains side gutters of `--tome-grid-padding`, as every other lane block has. In Case File Grid a headline with no description sits 0.5rem further from the tiles, because its bottom margin no longer collapses into the title's. - **Case File Grid, field manual with tile images on.** The extra top padding and second top rule inside each image card are gone; the cards match the default treatment's. - **Images that fill a box.** Case File Grid tile images, Record Roster logos and Proof Plates captures pass the media adapter `imgClassName`, `fill: true` and a `sizes` value, so an adapter that wraps its image can fill the box with a cover crop (tiles), `contain` (logos) or a cover crop from the top (captures). The built-in adapter renders the same `<img>` as before, now with a `sizes` attribute. No other change until a site sets a token or passes a context key: - Comparison Table's frame reads `--tome-dossier-table-radius`, `--tome-dossier-table-fill` and `--tome-dossier-table-rule`, and its solid fill reads `--tome-dossier-emphasis-bg`, `--tome-dossier-emphasis-fg` and `--tome-dossier-emphasis-accent`, each falling back to today's values. - Case File Grid takes a tile image fallback from the render context (`dossierTileMedia`, exported as `DOSSIER_TILE_MEDIA_CONTEXT_KEY` with `readDossierTileMedia`): a function that returns an image for a tile with no upload of its own. Its link rules use two-class selectors, so a global link reset no longer repaints them, and its hover transitions read `--tome-dossier-hover-ease` and `--tome-dossier-hover-duration`. - Term Ledger's display figure shadow reads `--tome-dossier-figure-shadow`.
  • 8083efd: Comparison Table renders a headerless table when it has no headline and gains a `descriptionAboveTable` option; four blocks gain tokens and three labels take weight 600. Visible changes on a default render: - **Comparison Table with no headline.** It now renders the table with no header and no eyebrow, any description directly above it, instead of rendering nothing. The schema still requires a headline, so this shows only for data that reaches the renderer from elsewhere. - **Labels at weight 600.** Record Roster's placeholder marks (`MARK 01` and so on), Evidence Plate's sources line (the `Sources` label and its links) and the stats line on Proof Plates' situational cards now set weight 600. The situational stats line is also uppercase. The stats line in the strip shell is unchanged. - **Case File Row link underlines on a band with an accent ink.** The cell links' and closing link's underlines follow the band's accent ink (`--blk-accent-ink`), as the link text already did. On a band without one (every built-in theme-relative band) they keep the theme accent, as before. - **Term Ledger figure image.** The figure passes the media adapter `imgClassName`, `fill: true` and a `sizes` value, so an adapter that wraps its image can fill the 4:5 window with a cover crop. The built-in adapter renders the same `<img>` with a second class and a `sizes` attribute; it looks the same. No other change until a site sets a token or an option: - Comparison Table: a `descriptionAboveTable` checkbox (default off) moves the description out of the header to sit directly above the table. Adding the field to a Payload config adds its column, so run your usual migration. Four tokens tune the header and column heads: `--tome-dossier-table-head-gap` (the stacked header's bottom margin, `--tome-space-xl` when unset), `--tome-dossier-rail-width` (the rail eyebrow column, `10.625rem`), `--tome-dossier-rail-eyebrow-size` (the rail eyebrow, `--tome-text-xs`, now apart from the column heads) and `--tome-dossier-column-head-wrap` (the column heads' `white-space`, `nowrap`). - Links: a site's global link reset such as `:root a { color: inherit }` no longer repaints the link colors of Case File Row (cell links and closing link), Ledger and Shift Rows (the CTA, with its hover and focus states), Evidence Sheet and Exhibit Artifact (the CTA), Zone Directory's dark cells or Lab Notes' handoff links: their color rules now use two-class selectors. The colors themselves are unchanged. - Lab Notes: the handoff link's gap transition reads `--tome-dossier-hover-duration` and `--tome-dossier-hover-ease` (`0.18s` and `ease` when unset); entry bodies are capped at `--tome-prose-max-width` when a site sets it and stay uncapped when it is unset. - Case File Row: the cell kicker's ink reads `--tome-dossier-cell-kicker-opacity`, a 0 to 1 number (0.62 when unset). - Evidence Plate: the eyebrow's ink reads `--tome-dossier-plate-eyebrow-opacity` (0.78 when unset), and the source links' underline transition reads `--tome-dossier-hover-duration` and `--tome-dossier-hover-ease` (`--tome-motion-fast` and `ease` when unset).
v0.5.4patch

37e8702: Fixed: dossier headings no longer override a site's own heading letter-spacing, font style or letter case unless the site opts into the heading voice. In 0.5.3 every block heading declared `font-style` and `text-transform` (and, on some headings, `font-family` or `letter-spacing`) from the heading-voice tokens with no fallback. With the tokens unset, those declarations made the heading take its parent's value, so a site rule such as `h2 { letter-spacing: -0.025em }` stopped applying: an Evidence Sheet opening headline lost its tight tracking and wrapped onto an extra line. The accent phrase had the same problem for its face, weight, case, tracking and line height. Those properties are now read only under an ancestor carrying the `data-tome-heading-voice` attribute, with `normal`, `none` or the inherited value as the fallback. Without the attribute a heading renders as it did before 0.5.3. The properties a heading always set (face, weight, tracking and line height on most) still read the tokens everywhere, falling back to the heading's own values. To keep a caps voice set through the tokens, add `data-tome-heading-voice` to the element that sets them.

  • 37e8702: Fixed: dossier headings no longer override a site's own heading letter-spacing, font style or letter case unless the site opts into the heading voice. In 0.5.3 every block heading declared `font-style` and `text-transform` (and, on some headings, `font-family` or `letter-spacing`) from the heading-voice tokens with no fallback. With the tokens unset, those declarations made the heading take its parent's value, so a site rule such as `h2 { letter-spacing: -0.025em }` stopped applying: an Evidence Sheet opening headline lost its tight tracking and wrapped onto an extra line. The accent phrase had the same problem for its face, weight, case, tracking and line height. Those properties are now read only under an ancestor carrying the `data-tome-heading-voice` attribute, with `normal`, `none` or the inherited value as the fallback. Without the attribute a heading renders as it did before 0.5.3. The properties a heading always set (face, weight, tracking and line height on most) still read the tokens everywhere, falling back to the heading's own values. To keep a caps voice set through the tokens, add `data-tome-heading-voice` to the element that sets them.
v0.5.3patch

e58ca95: Dossier headings read the heading-voice tokens and a new size token, stored bands paint on every block, and labels share one mono recipe. Visible changes on a default render: - **Labels.** These now use the mono face and weight 600: Fit Prose's kicker and column labels, Zone Directory's eyebrow, cell kicker and go label, and Spec Plate's cell labels (all were the page's body face at regular weight); Case File Row's cell kicker, Receipts Trio's attribution name and role, and Evidence Plate's stat numeral and label (were regular weight, already mono). Labels that sat on the `--tome-text-xs` step now use `--tome-type-size-xxs` (Phase Ledger, Practice Modes, Cost Ledger, Compare Ledger, Term Ledger, Wall Rows, and the labels above); on tome-ui's default scale the two steps are the same size. - **Cost Ledger coda.** It now reads `--tome-type-size-xl`, which equals the `--tome-text-h5` it used on tome-ui's default scale, so it renders the same there. No other change until a site sets a token or a block stores a band: - Every block heading and its accent phrase read `--tome-house-heading-*`, falling back to the heading's own values. - Every heading rule that sets a size (base headings and their `compact`, display, field-manual and rail variants) reads `--tome-dossier-heading-size`, falling back to its own size; a size an editor picks for a block (headline size or chrome text size) still wins over it. The heading-voice tokens carry no size; this one does. - Fit Prose, Spec Sheet, Spec Plate and Zone Directory paint a stored band (they ignored it before), and every block with a background field paints a band set per light and dark theme under tome-ui's `data-theme`. - On a block with a stored band, Case File Row's lit cell, Zone Directory's board and dark cells paint their own bands, and Cost Ledger's lit row gains a shadow. Proof Plates' flagged plate takes a band from `--tome-dossier-flag-bg`, `--tome-dossier-flag-fg` and `--tome-dossier-flag-accent-ink`. - Record Roster's index and coda, Evidence Plate's accent pull and Exhibit Artifact's tag read the band's companion inks first; the tag also reads `--tome-dossier-tag-bg` and `--tome-dossier-tag-fg`. Evidence Sheet's gap above the chips under the opening is `--tome-dossier-opening-chips-gap`.

  • e58ca95: Dossier headings read the heading-voice tokens and a new size token, stored bands paint on every block, and labels share one mono recipe. Visible changes on a default render: - **Labels.** These now use the mono face and weight 600: Fit Prose's kicker and column labels, Zone Directory's eyebrow, cell kicker and go label, and Spec Plate's cell labels (all were the page's body face at regular weight); Case File Row's cell kicker, Receipts Trio's attribution name and role, and Evidence Plate's stat numeral and label (were regular weight, already mono). Labels that sat on the `--tome-text-xs` step now use `--tome-type-size-xxs` (Phase Ledger, Practice Modes, Cost Ledger, Compare Ledger, Term Ledger, Wall Rows, and the labels above); on tome-ui's default scale the two steps are the same size. - **Cost Ledger coda.** It now reads `--tome-type-size-xl`, which equals the `--tome-text-h5` it used on tome-ui's default scale, so it renders the same there. No other change until a site sets a token or a block stores a band: - Every block heading and its accent phrase read `--tome-house-heading-*`, falling back to the heading's own values. - Every heading rule that sets a size (base headings and their `compact`, display, field-manual and rail variants) reads `--tome-dossier-heading-size`, falling back to its own size; a size an editor picks for a block (headline size or chrome text size) still wins over it. The heading-voice tokens carry no size; this one does. - Fit Prose, Spec Sheet, Spec Plate and Zone Directory paint a stored band (they ignored it before), and every block with a background field paints a band set per light and dark theme under tome-ui's `data-theme`. - On a block with a stored band, Case File Row's lit cell, Zone Directory's board and dark cells paint their own bands, and Cost Ledger's lit row gains a shadow. Proof Plates' flagged plate takes a band from `--tome-dossier-flag-bg`, `--tome-dossier-flag-fg` and `--tome-dossier-flag-accent-ink`. - Record Roster's index and coda, Evidence Plate's accent pull and Exhibit Artifact's tag read the band's companion inks first; the tag also reads `--tome-dossier-tag-bg` and `--tome-dossier-tag-fg`. Evidence Sheet's gap above the chips under the opening is `--tome-dossier-opening-chips-gap`.
  • cc5d280: Twelve dossier blocks gain an opt-in scroll reveal, switched on through the render context; with it off they render exactly as before. - **Blocks.** Cost Ledger, Spec Plate, Spec Sheet, Fit List, Fit Prose, Compare Ledger, Grants Ledger, Phase Ledger, Practice Modes, Zone Directory, Term Ledger and Wall Rows. The five blocks that already reveal on scroll are unchanged. - **Turning it on.** Pass `context={{ dossierReveal: true }}` to `RenderBlocks` / `RenderBlock`, or straight to a renderer, for the pack's own timing; pass an object to match a site's motion: `duration` and `stagger` in seconds, `ease` (any GSAP ease string), `start` (a ScrollTrigger start) and `rise` (a CSS length). Nothing is stored on the block, so there is no schema change and no migration. - **Off by default.** Without the context key a block renders no extra wrapper, class, attribute, inline style, stylesheet or client component. - **Accessibility.** Pending content is hidden by opacity only, and only once `RevealGate` (from `@wabbit/tome-blocks-house/reveal-gate`) has marked `<html>` before first paint, so content shows when JavaScript is off or fails. Under `prefers-reduced-motion: reduce` nothing is hidden and nothing moves. - New exports from `./render`: `readDossierReveal`, `DOSSIER_REVEAL_CONTEXT_KEY` and the `DossierRevealOptions` and `DossierRenderContext` types.
v0.5.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.5.1patch

87ebabd: Spec Sheet labels use the pack's mono label style: mono face, the `xxs` step and weight 600, as the other dossier blocks' labels do. Visible change: the label above each value was the page's body face at the `xs` step and regular weight. It kept the uppercase and wide tracking of a mono label without the face. The tracking now falls back to `0.2em` on a site that does not load the house tokens.

  • 87ebabd: Spec Sheet labels use the pack's mono label style: mono face, the `xxs` step and weight 600, as the other dossier blocks' labels do. Visible change: the label above each value was the page's body face at the `xs` step and regular weight. It kept the uppercase and wide tracking of a mono label without the face. The tracking now falls back to `0.2em` on a site that does not load the house tokens.
v0.5.0minor

c8f15cc: **BREAKING:** Six blocks now lay out on the page's reading lanes by default, and Case File Grid drops its 80rem width cap, so these blocks move on the page. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.1 or later (the peer range is now `>=0.8.1 <1.0.0`; 0.8.1 adds the lane grid's `lead` slot, which Term Ledger, Wall Rows, Proof Plates, Evidence Sheet and Record Roster use). No content or config changes. If the new placement does not suit a page, set the lane tokens below on that page; there is no switch back to the old flex layout. What you will see move on `cost-ledger`, `grants-ledger`, `practice-modes`, `zone-directory`, `case-file-row` and `compare-ledger`, from 1024px wide: - **Kicker.** It sits in the left marginalia lane at the lane's full width. It used to be as wide as its words (Cost Ledger, Grants Ledger), a fifth of the row (Practice Modes), a quarter (Case File Row) or 16rem (Zone Directory). Its top rule stops short of the heading. - **Heading.** It starts at the reading column in every block, one lane in from the content edge, whether or not there is a kicker. Compare Ledger has no kicker, so its heading now starts one lane in and the left lane stays empty. - **Intros.** Cost Ledger's intro and its centered coda sit in the reading column instead of starting at the content edge; Zone Directory's and Case File Row's descriptions sit under the heading. Intro paragraphs, and the closing statements of Practice Modes and Compare Ledger, are capped at `--tome-prose-max-width` when you set it. - **Content.** Tables, ledger rows, mode columns, cells and closing lines run the whole content column. Case File Row used to sit in an 80rem box with its own side padding; it now runs gutter to gutter. Cost Ledger's rows and column labels are inset to line up with the inset lit row. - **Band.** In a full-row block wrapper (RenderBlocks' default) the background paints the whole row and the content sits on the page's content lines, with the page grid's gutter as the only side inset. - **Narrower screens.** Below 1024px everything stacks on the content column: kicker, heading, then content. Practice Modes and Case File Row used to put the kicker beside the heading from 900px, and Cost Ledger, Grants Ledger and Zone Directory whenever the row had room; they now stack until 1024px. The lane widths are the page grid's: `--tome-grid-marginalia-left-cols`, `--tome-grid-marginalia-right-cols` and `--tome-grid-prose-pad-cols` (defaults 3, 3 and 2). Without tome-ui's page grid the blocks lay out the same tracks themselves, with `--tome-grid-padding` as the side gutter. **Case File Grid** loses its 80rem cap and its inline padding: its strip, title, tiles and closing link run the full width the page gives the block (the content column, gutter to gutter, on the page grid), like the pack's other full-width blocks. On a page without the page grid it now sits flush with its container, so give the container side padding. The other blocks that move in this release are listed in its companion entry; `ledger` and `shift-rows` render as before.

  • c8f15cc: **BREAKING:** Six blocks now lay out on the page's reading lanes by default, and Case File Grid drops its 80rem width cap, so these blocks move on the page. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.1 or later (the peer range is now `>=0.8.1 <1.0.0`; 0.8.1 adds the lane grid's `lead` slot, which Term Ledger, Wall Rows, Proof Plates, Evidence Sheet and Record Roster use). No content or config changes. If the new placement does not suit a page, set the lane tokens below on that page; there is no switch back to the old flex layout. What you will see move on `cost-ledger`, `grants-ledger`, `practice-modes`, `zone-directory`, `case-file-row` and `compare-ledger`, from 1024px wide: - **Kicker.** It sits in the left marginalia lane at the lane's full width. It used to be as wide as its words (Cost Ledger, Grants Ledger), a fifth of the row (Practice Modes), a quarter (Case File Row) or 16rem (Zone Directory). Its top rule stops short of the heading. - **Heading.** It starts at the reading column in every block, one lane in from the content edge, whether or not there is a kicker. Compare Ledger has no kicker, so its heading now starts one lane in and the left lane stays empty. - **Intros.** Cost Ledger's intro and its centered coda sit in the reading column instead of starting at the content edge; Zone Directory's and Case File Row's descriptions sit under the heading. Intro paragraphs, and the closing statements of Practice Modes and Compare Ledger, are capped at `--tome-prose-max-width` when you set it. - **Content.** Tables, ledger rows, mode columns, cells and closing lines run the whole content column. Case File Row used to sit in an 80rem box with its own side padding; it now runs gutter to gutter. Cost Ledger's rows and column labels are inset to line up with the inset lit row. - **Band.** In a full-row block wrapper (RenderBlocks' default) the background paints the whole row and the content sits on the page's content lines, with the page grid's gutter as the only side inset. - **Narrower screens.** Below 1024px everything stacks on the content column: kicker, heading, then content. Practice Modes and Case File Row used to put the kicker beside the heading from 900px, and Cost Ledger, Grants Ledger and Zone Directory whenever the row had room; they now stack until 1024px. The lane widths are the page grid's: `--tome-grid-marginalia-left-cols`, `--tome-grid-marginalia-right-cols` and `--tome-grid-prose-pad-cols` (defaults 3, 3 and 2). Without tome-ui's page grid the blocks lay out the same tracks themselves, with `--tome-grid-padding` as the side gutter. **Case File Grid** loses its 80rem cap and its inline padding: its strip, title, tiles and closing link run the full width the page gives the block (the content column, gutter to gutter, on the page grid), like the pack's other full-width blocks. On a page without the page grid it now sits flush with its container, so give the container side padding. The other blocks that move in this release are listed in its companion entry; `ledger` and `shift-rows` render as before.
  • 1656e52: **BREAKING:** Ten more blocks lay out on the page's reading lanes by default, so they move on the page. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.1 or later (the peer range is now `>=0.8.1 <1.0.0`; these blocks use the lane grid's `lead` slot, new in 0.8.1). No content or config changes, and every display option keeps working (`term-ledger`'s `headingFace` and `spec-measure`, `evidence-sheet`'s `ledgerRails` and `showCta`). If the new placement does not suit a page, set the lane tokens on that page; there is no switch back to the old layout. What you will see move, from 1024px wide unless a line says otherwise: - **Fit List, Fit Prose, Phase Ledger.** The kicker sits in the left marginalia lane at the lane's full width, its top rule stopping short of the heading; the heading starts at the reading column. They used to sit in a flex row (Fit List's kicker as wide as its words up to 18rem, Fit Prose's 16rem) or a grid with a 20% kicker column (Phase Ledger). Phase Ledger's intro stays under the heading and holds `--tome-prose-max-width` (62ch when unset). Fit List's two lists hold `--tome-prose-max-width` when you set it. Columns, rows and closing lines run the whole content width; the closing statements are capped at `--tome-prose-max-width` when it is narrower than 52ch. - **Receipts Trio.** The kicker and heading move to the left lane and the reading column (they were a one-quarter / three-quarter split inside an 80rem box). The body sits in the reading column with its text on `--tome-prose-max-width` (62ch when unset); the quote grid runs the whole content width, gutter to gutter. - **Proof Plates, card shell** (`shell: 'situational'`, or `auto` with no plate kickers). Kicker in the left lane, heading from the reading column (was one quarter / three quarters inside an 80rem box). An empty kicker no longer reserves its column. The intro hangs from the prose lane to the end of the reading column; the cards and the closing statement run the content width. - **Proof Plates, strip shell; Term Ledger; Wall Rows.** The kicker's rule and the heading (still held to 24ch) run the content width as before. The intro and the closing statement now start at the prose lane and run to the end of the reading column, instead of starting at the content edge; the intro text is capped at `--tome-prose-max-width` (62ch when unset) and the close at the narrower of 52ch and that token. Rows, walls and plates run the whole content width. Proof Plates loses its 80rem box. Term Ledger's optional figure still sits beside its rows, inside the content width. - **Evidence Sheet, ledger treatment.** The rail and the opening narrative swap sides and proportions: the rail now sits on the left, from the content edge to the prose lane and held one column short of it, and the opening hangs from the prose lane to the end of the reading column, its body on `--tome-prose-max-width` (62ch when unset). With `ledgerRails: 'split'` the opening sits in the prose lane and the right rail runs from the end of the prose lane to the content edge, again one column clear. The gap is one lane-grid column on any lane settings (it was a fluid gap between 2 : 3 : 2 tracks). Below 1024px it stacks as before: opening, then the rails. - **Evidence Sheet, table, grid and strip treatments.** The top strip, rows, chips and call to action run the whole content width, gutter to gutter, instead of an 80rem box. The call to action keeps its own width. - **Record Roster.** The kicker sits in the left lane with the rows beside it, from the prose lane to the end of the reading column (they used to stack, kicker above rows, in an 80rem box). The logo grid runs the content width; the coda centers in the reading column. Below 1024px the kicker stacks above the rows as before. - **Evidence Plate.** The eyebrow sits in the left lane above the stats; the stat row runs the content width; the body (its text on `--tome-prose-max-width`, 62ch when unset) and the sources sit in the reading column; the pull statement runs from the content edge toward the end of the reading column, still held to 26ch. It used to sit in an 80rem box. - **Bands.** In a full-row block wrapper (RenderBlocks' default) each band's background paints the whole row and its content sits on the page's content lines, with the page grid's gutter as the only side inset. - **Narrower screens.** Below 1024px everything stacks on the content column. Blocks that used to put the kicker beside the heading from 900px (Fit List, Fit Prose and Phase Ledger when the row had room, Receipts Trio and Proof Plates' cards from 900px) now stack until 1024px. The lane widths are the page grid's: `--tome-grid-marginalia-left-cols`, `--tome-grid-marginalia-right-cols` and `--tome-grid-prose-pad-cols` (defaults 3, 3 and 2). Without tome-ui's page grid the blocks lay out the same tracks themselves, with `--tome-grid-padding` as the side gutter. `ledger` and `shift-rows` render as before.
  • 5ea0301: Term Ledger, Comparison Table and Evidence Sheet gain opt-in display options, all off by default. Additive: a block that does not set an option renders the same markup and styles as before. Adding the fields to a Payload config adds their columns, so run your usual migration. - **Term Ledger.** A `headingFace` select (`head` by default, `display` for a larger, light serif heading) and a third variant, `spec-measure`: the `spec` band with each row body capped at `--tome-prose-max-width` (62ch when unset) while the rules still run the full width. - **Comparison Table.** A `railHeader` checkbox lays the eyebrow out as a narrow ruled column beside a large headline, whose `*accent*` runs drop to their own line in the accent ink; the headline reads the `--tome-house-heading-*` heading-voice tokens, falling back to the block's own voice. An `emphasisHighlight` checkbox paints highlighted columns with a solid fill in the band's text color instead of the subtle accent tint. - **Evidence Sheet.** A `ledgerRails` select for the Ledger treatment (`single` by default). `split` cuts the rows by count across a left and a mirrored right rail, centers the opening narrative between them and moves the scope and stack chips under it; with no opening, the chips and the call to action close the right rail.
v0.4.1patch

f18bfd0: The Ledger and Shift Rows call-to-action links now use the `rel` a registered CTA resolver supplies. With no resolver registered the markup is unchanged: a new-tab link still gets `target="_blank"` and `rel="noopener noreferrer"`. Needs `@wabbit/tome-blocks-house` with CTA resolver registration to take effect.

  • f18bfd0: The Ledger and Shift Rows call-to-action links now use the `rel` a registered CTA resolver supplies. With no resolver registered the markup is unchanged: a new-tab link still gets `target="_blank"` and `rel="noopener noreferrer"`. Needs `@wabbit/tome-blocks-house` with CTA resolver registration to take effect.
v0.4.0minor

6be9a25: Evidence Sheet can now show its call to action in the Strip and Ledger treatments, with a new "Show call to action" toggle. Additive: the new `showCta` checkbox defaults to on for new blocks, and existing documents render as before (link in Table and Grid only) until they are re-saved. Unticking it hides the link in every treatment. In Strip the link sits on its own line after the row; in Ledger it closes the opening narrative, or the ledger column when there is no narrative. The ledger now has a gap between its two columns at wide widths.

  • 6be9a25: Evidence Sheet can now show its call to action in the Strip and Ledger treatments, with a new "Show call to action" toggle. Additive: the new `showCta` checkbox defaults to on for new blocks, and existing documents render as before (link in Table and Grid only) until they are re-saved. Unticking it hides the link in every treatment. In Strip the link sits on its own line after the row; in Ledger it closes the opening narrative, or the ledger column when there is no narrative. The ledger now has a gap between its two columns at wide widths.
v0.3.3patch

483e0a1: The dossier pack's comparison table, wall rows and spec plate previews now use fictional content. The comparison table shows an invented analytics plan ladder, wall rows tell an invented company story, and the spec plate lists generic technology cells. Block fields and variants are unchanged.

  • 483e0a1: The dossier pack's comparison table, wall rows and spec plate previews now use fictional content. The comparison table shows an invented analytics plan ladder, wall rows tell an invented company story, and the spec plate lists generic technology cells. Block fields and variants are unchanged.
  • d08fc38: The exhibit artifact block no longer overflows a phone-width screen. The block's root is a figure whose default side margins were added on top of its full width; it now sizes with border-box and zero inline margin, and long header and subject text wraps.
v0.3.2patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.3.1patch

8c84e70: Accent and primary text on solid-dark bands is readable in light themes. case-file-row, receipts-trio, evidence-sheet, term-ledger, ledger, grants-ledger, proof-plates, cost-ledger and zone-directory read the band's accent and primary inks from `resolveBackground`, with their previous tokens as fallback. Flagged proof plates and dark zone-directory cells no longer take a solid-dark band, because nothing paints that band yet and its light inks would land on the light card; they render exactly as before.

  • 8c84e70: Accent and primary text on solid-dark bands is readable in light themes. case-file-row, receipts-trio, evidence-sheet, term-ledger, ledger, grants-ledger, proof-plates, cost-ledger and zone-directory read the band's accent and primary inks from `resolveBackground`, with their previous tokens as fallback. Flagged proof plates and dark zone-directory cells no longer take a solid-dark band, because nothing paints that band yet and its light inks would land on the light card; they render exactly as before.
v0.3.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 24 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - ProofPlates is a client component; it is now a server-safe wrapper (`ProofPlates.tsx`, which renders the stylesheet and registers the renderer) around `ProofPlates.client.tsx`, with its exported types re-exported. Four blocks already split into a server renderer plus a `...Motion.client.tsx` keep that split; the client half imports the same `.tome-css` for class names. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 24 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - ProofPlates is a client component; it is now a server-safe wrapper (`ProofPlates.tsx`, which renders the stylesheet and registers the renderer) around `ProofPlates.client.tsx`, with its exported types re-exported. Four blocks already split into a server renderer plus a `...Motion.client.tsx` keep that split; the client half imports the same `.tome-css` for class names. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.2.2patch

7862f30: Swaps the plain primary token for the on-solid-dark pairing on text and borders that sit on a dark surface in three packs. `@wabbit/tome-blocks-dossier-pack`: the `evidence-sheet` block's `ledger` treatment now uses the on-solid-dark pairing for its kicker, headline emphasis, body link and row-label text, instead of the plain primary token — that token is a surface-tint fill, not guaranteed legible as text on the block's dark surface. `@wabbit/tome-blocks-cinema-pack`: the `scene-plate` block's seated panel kicker and body emphasis text get the same on-solid-dark pairing, since the panel itself is a partially-opaque dark surface over the scene image. `@wabbit/tome-blocks-catalog-pack`: the `price-table` block's highlighted-tier border now uses the on-solid-dark pairing specifically on the `dark` variant, leaving the default/light variant's border on the plain primary token unchanged.

  • 7862f30: Swaps the plain primary token for the on-solid-dark pairing on text and borders that sit on a dark surface in three packs. `@wabbit/tome-blocks-dossier-pack`: the `evidence-sheet` block's `ledger` treatment now uses the on-solid-dark pairing for its kicker, headline emphasis, body link and row-label text, instead of the plain primary token — that token is a surface-tint fill, not guaranteed legible as text on the block's dark surface. `@wabbit/tome-blocks-cinema-pack`: the `scene-plate` block's seated panel kicker and body emphasis text get the same on-solid-dark pairing, since the panel itself is a partially-opaque dark surface over the scene image. `@wabbit/tome-blocks-catalog-pack`: the `price-table` block's highlighted-tier border now uses the on-solid-dark pairing specifically on the `dark` variant, leaving the default/light variant's border on the plain primary token unchanged.
v0.2.1patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.
v0.2.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.1.4patch

Updated dependencies [e044594] - @wabbit/tome-blocks-core@0.16.6

  • Updated dependencies [e044594] - @wabbit/tome-blocks-core@0.16.6
v0.1.3patch

Updated dependencies [a2f2dfa] - @wabbit/tome-blocks-house@0.3.0

  • Updated dependencies [a2f2dfa] - @wabbit/tome-blocks-house@0.3.0
v0.1.2patch

37fca4e: Text over the solid-dark surface now uses `var(--tome-color-on-solid-dark, var(--tome-color-surface-inverse))` — the tome-ui pairing idiom — so consumers on tome-ui < 0.10 (which lacks `on-solid-dark`) no longer render dark-on-black in light theme (case-file-grid, case-file-row, evidence-sheet, ledger, receipts-trio, zone-directory).

  • 37fca4e: Text over the solid-dark surface now uses `var(--tome-color-on-solid-dark, var(--tome-color-surface-inverse))` — the tome-ui pairing idiom — so consumers on tome-ui < 0.10 (which lacks `on-solid-dark`) no longer render dark-on-black in light theme (case-file-grid, case-file-row, evidence-sheet, ledger, receipts-trio, zone-directory).
  • Updated dependencies [7850b7a] - @wabbit/tome-blocks-house@0.2.0
v0.1.1patch

b529fa6: Demo content is brand-free: compare-ledger's emphasized column ("The Wabbit way" → "The documented way"), shift-rows' heading (no platform name), and a demo subject renamed away from an internal persona name. Catalog captures are a public surface for anyone licensing Tome; demo fiction must not name Wabbit, Tome, or house personas. Demo props now carry `_variant`, so gallery thumbs for term-ledger, ledger, grants-ledger, shift-rows and fit-list render the requested variant instead of the default.

  • b529fa6: Demo content is brand-free: compare-ledger's emphasized column ("The Wabbit way" → "The documented way"), shift-rows' heading (no platform name), and a demo subject renamed away from an internal persona name. Catalog captures are a public surface for anyone licensing Tome; demo fiction must not name Wabbit, Tome, or house personas. Demo props now carry `_variant`, so gallery thumbs for term-ledger, ledger, grants-ledger, shift-rows and fit-list render the requested variant instead of the default.

Blocks Campaign Pack

v0.6.2
v0.6.2patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.6.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.6.0minor

8abed8c: New `give-form` block: a giving form that hands off to secure checkout through your site's own server action. Amount chips and a custom amount of equal weight, one-time or monthly, optional fee cover. The form reads its amounts, currency and bounds from your Prices and posts natively without script. One-time is the only pre-selection: no chip, no Monthly and no fee cover is ever checked in the server markup, and no field can change that. The button repeats the total ("Give $51.50", "Give $25 a month"), bounds and format errors show inline in a live region, and the block renders nothing until your site wires the action and a Price. Gallery previews render an inert form with a visible "Demo" tag. `./server` widens, additively: - `createHydratedRenderers` takes `getPrices`, `giveAction`, `feeCover` and `locale`, and `getCampaignFigures` is now optional (a site that passes it gets the same map as before; with no options the map is empty). `campaign-hero` is wrapped for `portrait` as well as `night-launch`, and every wrapped block receives `campaignLocale`. - New: `createPriceSource` (a public read of your Prices collection), `readGiveSubmission` (parses the post into minor units, never rounding), `isGiveSubmissionError`, `withGiveRuntime`, `outOfRangeMessage`, `computeGiveFeeCents`, `GIVE_FIELDS`, `GIVE_IDLE_STATE`, and the types `PriceFacts`, `PriceFactsSource`, `GiveFormState`, `GiveAction`, `GiveFeeCoverRule`, `GiveRuntime`, `GiveSubmission`, `GiveSubmissionError`. The pack still has no `@wabbit/tome-economy` peer. Existing blocks render byte-identically (static and hydrated). Three more pieces in the same minor: - **`membership-card` (new).** Each tier as a wallet-proportion card (a 14px radius, a ratio of at least 1.59): the number first, then what it covers and what it doesn't. A `live` tier is bound to a recurring Price (`month` or `year`) through the same `getPrices` source as the give form, so the amount and period are the Price's own; a tier whose Price is missing, inactive or one-time is dropped, and with no tier left the block renders nothing. The monthly figure prints only from `monthlyPriceRef`, never computed. `source: authored` prints editor labels for dues collected offline. At most one tier is `recommended` (a 2px ink border all round plus a visually hidden "(Recommended)"; no badge, no popularity copy). Below 55rem of the block's own container width a CSS-only picker (native radios, `:has(:checked)`) shows one card at a time, opening on the recommended tier; from 55rem all cards sit side by side. Works without script. `createHydratedRenderers({ getPrices })` now also wraps `membership-card`. - **`campaign-terms` `ledger` (new variant).** The block gains its first variants, `row` (the default; a stored block without `_variant` is `row`) and `ledger`: a `heading`, an `intro` and a `statement` (one `*run*` renders `b[data-accent]`) beside up to six terms as a definition list. `terms` allows six rows at the schema, and a field validate keeps `row` at three. On SQL databases the injected `_variant` select is one new nullable column, so regenerate your migration. - **`campaign-case` (new).** The case for a campaign in the reading column (rich text `body`; no body, no block), with who is accountable and the last update in the start margin, and budget lines, their source and a terms link in the end margin. The budget total is computed from the lines, never typed, and a development warning fires when it differs from the figures source's goal. The margins fold after the prose below 1024px. `createHydratedRenderers({ getCampaignFigures })` now also wraps `campaign-case`, so it reads the campaign's currency and goal. Additive part hooks on existing output (the stored-render goldens strip exactly these): `campaign-terms` `row` gains `data-variant="row"` and `data-block-variant="row"` on its root. The `campaign-pack` bundle and package descriptions widen to "campaigns and giving". Checkpoint B additions to `give-form` (each optional and blank by default; a form that writes none of them renders byte for byte as before): - `amountHint`: one line under the amounts, with `{min}` and `{max}` filled from the one-time Price's bounds ("Any amount from $5 to $10,000."), joined to the custom field's description. A hint that names a bound the Price lacks is held back. - `largeGift { note, linkLabel, href }`: the site's line after the over-the-maximum message ("For a larger gift, write to Ruth and she'll set it up with you."), with `{link}` marking the link. The island shows it with that message only. - `labels.feeTotal` / `labels.feeTotalMonthly`: a visible total over the button while fee cover is ticked ("Total $51.50: your $50 gift and $1.50 toward card fees."). `labels.monthlyCharge`: a line under the button while Monthly is chosen ("Charged $25 today, then on the 25th of each month until you stop."). Both are filled by the island after hydration, so the server markup holds them empty. - `outOfRangeMessage` takes an optional `contact`, appended to the above-maximum message only, and `./server` adds `largeGiftText(largeGift)` to word it from the block's note. On SQL databases the new fields are nullable columns, so regenerate your migration.

  • 8abed8c: New `give-form` block: a giving form that hands off to secure checkout through your site's own server action. Amount chips and a custom amount of equal weight, one-time or monthly, optional fee cover. The form reads its amounts, currency and bounds from your Prices and posts natively without script. One-time is the only pre-selection: no chip, no Monthly and no fee cover is ever checked in the server markup, and no field can change that. The button repeats the total ("Give $51.50", "Give $25 a month"), bounds and format errors show inline in a live region, and the block renders nothing until your site wires the action and a Price. Gallery previews render an inert form with a visible "Demo" tag. `./server` widens, additively: - `createHydratedRenderers` takes `getPrices`, `giveAction`, `feeCover` and `locale`, and `getCampaignFigures` is now optional (a site that passes it gets the same map as before; with no options the map is empty). `campaign-hero` is wrapped for `portrait` as well as `night-launch`, and every wrapped block receives `campaignLocale`. - New: `createPriceSource` (a public read of your Prices collection), `readGiveSubmission` (parses the post into minor units, never rounding), `isGiveSubmissionError`, `withGiveRuntime`, `outOfRangeMessage`, `computeGiveFeeCents`, `GIVE_FIELDS`, `GIVE_IDLE_STATE`, and the types `PriceFacts`, `PriceFactsSource`, `GiveFormState`, `GiveAction`, `GiveFeeCoverRule`, `GiveRuntime`, `GiveSubmission`, `GiveSubmissionError`. The pack still has no `@wabbit/tome-economy` peer. Existing blocks render byte-identically (static and hydrated). Three more pieces in the same minor: - **`membership-card` (new).** Each tier as a wallet-proportion card (a 14px radius, a ratio of at least 1.59): the number first, then what it covers and what it doesn't. A `live` tier is bound to a recurring Price (`month` or `year`) through the same `getPrices` source as the give form, so the amount and period are the Price's own; a tier whose Price is missing, inactive or one-time is dropped, and with no tier left the block renders nothing. The monthly figure prints only from `monthlyPriceRef`, never computed. `source: authored` prints editor labels for dues collected offline. At most one tier is `recommended` (a 2px ink border all round plus a visually hidden "(Recommended)"; no badge, no popularity copy). Below 55rem of the block's own container width a CSS-only picker (native radios, `:has(:checked)`) shows one card at a time, opening on the recommended tier; from 55rem all cards sit side by side. Works without script. `createHydratedRenderers({ getPrices })` now also wraps `membership-card`. - **`campaign-terms` `ledger` (new variant).** The block gains its first variants, `row` (the default; a stored block without `_variant` is `row`) and `ledger`: a `heading`, an `intro` and a `statement` (one `*run*` renders `b[data-accent]`) beside up to six terms as a definition list. `terms` allows six rows at the schema, and a field validate keeps `row` at three. On SQL databases the injected `_variant` select is one new nullable column, so regenerate your migration. - **`campaign-case` (new).** The case for a campaign in the reading column (rich text `body`; no body, no block), with who is accountable and the last update in the start margin, and budget lines, their source and a terms link in the end margin. The budget total is computed from the lines, never typed, and a development warning fires when it differs from the figures source's goal. The margins fold after the prose below 1024px. `createHydratedRenderers({ getCampaignFigures })` now also wraps `campaign-case`, so it reads the campaign's currency and goal. Additive part hooks on existing output (the stored-render goldens strip exactly these): `campaign-terms` `row` gains `data-variant="row"` and `data-block-variant="row"` on its root. The `campaign-pack` bundle and package descriptions widen to "campaigns and giving". Checkpoint B additions to `give-form` (each optional and blank by default; a form that writes none of them renders byte for byte as before): - `amountHint`: one line under the amounts, with `{min}` and `{max}` filled from the one-time Price's bounds ("Any amount from $5 to $10,000."), joined to the custom field's description. A hint that names a bound the Price lacks is held back. - `largeGift { note, linkLabel, href }`: the site's line after the over-the-maximum message ("For a larger gift, write to Ruth and she'll set it up with you."), with `{link}` marking the link. The island shows it with that message only. - `labels.feeTotal` / `labels.feeTotalMonthly`: a visible total over the button while fee cover is ticked ("Total $51.50: your $50 gift and $1.50 toward card fees."). `labels.monthlyCharge`: a line under the button while Monthly is chosen ("Charged $25 today, then on the 25th of each month until you stop."). Both are filled by the island after hydration, so the server markup holds them empty. - `outOfRangeMessage` takes an optional `contact`, appended to the above-maximum message only, and `./server` adds `largeGiftText(largeGift)` to word it from the block's note. On SQL databases the new fields are nullable columns, so regenerate your migration.
  • c81b0af: Adds the Running Total (`campaign-count` `line`, `campaign-hero` `portrait`), `giving-levels` on `campaign-tiers`, per-source currency and sample labels, and a count-up motion. Everything is additive and opt-in: a stored block that sets none of the new fields renders as before, apart from the part hooks listed at the end. - **Running Total.** `campaign-count` `line` and `campaign-hero` `portrait` (with `showLine`) share one renderer: a filled sentence (`lineTemplate`), a fallback (`lineFallback`) for a source with no goal, an optional fund line (`lineMeta`) and a bar label (`barLabel`). Three states: live, partial (no goal, so no bar and no percent), and none, where nothing renders. `line` paints no band and does not bleed, so it inherits the surface it sits on. `campaign-count` is now `nestable` (`minColSpan: 1`). - **`campaign-hero` `portrait`.** A photo from the page edge beside the copy, with its label and credit in a `figcaption` after the image, weight-only emphasis (`*run*` becomes `b[data-accent]`), two real buttons, `headlineHref`, `headingLevel`, `mediaSide`, and `hideWithoutFigures` (the block renders nothing without live figures, so a band around it goes with it). - **`campaign-hero` `portrait` eyebrow.** One `*run*` in the eyebrow renders as `b[data-accent]` (a record number such as "_Since 1931_"), as in the headline. An eyebrow without asterisks prints exactly as before. - **`campaign-tiers` `giving-levels`.** Plain cells for a gift or membership: a price label, a title, one sentence and a link. It never prints the "PLEDGE" word, features or a recommended highlight. New `note` and `sampleLabel` fields. - **`campaign-count` captions and tokens.** A `captions` group (`raised`, `goal`, `count`, `daysLeft`; blank keeps today's words) and the tokens `{raised}` (= `{pledged}`), `{count}` (= `{backers}`) and `{updated}`. - **Figures.** `CampaignFigures` gains `currency` (ISO 4217), `sampleLabel` and `updatedAt`. Money goes through the new `formatMoney` (no currency: the existing US-dollar format, unchanged; a currency: `Intl` with the minor-unit exponent, so JPY is not divided by 100). Also exported: `formatMoneyExact`, `formatUpdated`, `readFigureMeta`, `fillStatTemplateParts`. - **Motion.** `RunningTotalMotion` counts each number from 0 to the real figure and lets the bar fill to its real width, once. Reduced motion and no-script render the final values. `RunningTotalHeadScript` (from `./render`) is an optional head script that holds a bar at zero until it plays. The motion ends on the figure the server printed even when the island starts twice on one element (React StrictMode mounts, cleans up and mounts an effect again; before, the second start read back the zero the first had printed and the Ledger Strip and a nested line ended on $0). While the head script has armed the page, the counting numerals are held invisible (their space kept) until the island plays, with the same 3s restore as the bar, so no frame paints the final value before the count starts. Tokens: `--campaign-dur-bar`, `--campaign-ease`, `--campaign-bar-fill`, `--campaign-bar-track`, `--campaign-bar-height`, `--campaign-bar-radius`, `--campaign-hero-ratio`. Additive part hooks on existing variants (the stored-render snapshots were re-cut for exactly these, and a test strips them to get the previous markup back byte for byte): `data-block-variant` on the root of `campaign-hero` `ledger` / `counter` / `night-launch`, `campaign-count` `ledger-strip` / `counter-tally`, and `campaign-tiers` `wall` / `board`; on `ledger-strip` also `data-campaign-count`, `data-campaign-cells`, `data-campaign-cell`, `data-figure`, `data-campaign-value`, `data-count-to`, `data-campaign-caption`, `data-campaign-bar` and `data-campaign-rule`, plus a `display: contents` wrapper (`data-running-total`, `data-tome-motion="self"`, `data-currency`, `data-locale`) around the figure row and bar; on `counter-tally` also `data-campaign-count`. Checkpoint B: `campaign-count` `ledger-strip` gains an optional **Hide the day counter** (`hideDayLocator`), which leaves out the "Day 55 of 77" strip above the figures (unticked, the strip prints as before). And a fix: the Ledger Strip's and Counter Tally's bar now read the documented `--campaign-bar-fill`, `--campaign-bar-track`, `--campaign-bar-height` and `--campaign-bar-radius`, each falling back to the value it had, so a theme's bar fill reaches the strip as it already reached the line. With none of them set, the computed styles are unchanged. The Running Total's `lineMeta` (the fund line after the sample label, on `campaign-count` `line` and `campaign-hero` `portrait`) now fills the line's tokens, so a page can print "Updated {updated}" beside "Demo figures". A `lineMeta` with no tokens prints exactly as before; one that uses a token with no figure is held back whole, as the line is.
v0.5.4patch

d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.

  • d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.
v0.5.3patch

fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.

  • fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.
v0.5.2patch

b452c7b: Campaign Hero's Counter and Night Launch variants now stack on narrow screens, and Campaign Tiers gains stacked Wall seams plus Wall and Board tokens. The hero's notes and the Board hand note also share four new face tokens. Visible change on a default render: - **Hero narrow grid (fix).** Below 1024px the Counter and Night Launch heroes now stack into one column like Ledger. Their two-column rules (specificity 0,3,0) outranked the single-column rule (0,1,0), so both kept two columns at phone width; the variant splits now apply from 1024px only. From 1024px nothing changes. - **Stacked Wall seams (fix).** At 767px or less, Wall cells drop their right-hand seam and take a rule under each cell but the last, in the wall rule color (`--tome-campaign-tier-wall-rule`). Before, stacked cells kept a right seam and had nothing between them. - **Wall buttons are block boxes.** `.btn` is `display: block` (it was an `inline-block` at full width), so the line-box strut under the button, a few px, is gone and each cell ends that much sooner. The button's size and label do not change. - **Wall button color rules carry two classes** (`.cta .btnSolid`, `.cta .btnGhost`), like the Board's `.rowCta .quiet`, so a site's `:root a { color: inherit }` (0,1,1) no longer repaints the solid button's label or the ghost's ink. With no such site rule the colors are unchanged. No other change until a site sets a token (each falls back to today's value): - Hero figure placeholder: `--tome-campaign-placeholder-pad` (`--tome-space-md`), `--tome-campaign-placeholder-bg` (`--tome-color-surface-tint`), `--tome-campaign-placeholder-border` (`1px dashed` accent ink at 45%). - Hand-note face, shared by the hero's CTA note and hand note and the Tiers Board hand note: `--tome-campaign-hand-family` (`--tome-type-serif`), `--tome-campaign-hand-style` (`italic`), `--tome-campaign-hand-weight` (hero `--tome-type-weight-medium`, Board `inherit`), `--tome-campaign-hand-ink` (accent ink at 85%). - Wall cell rhythm: `--tome-campaign-tier-cell-gap` (`1rem`), `--tome-campaign-tier-name-gap`, `-price-gap` and `-feats-gap` (`0`, each a `margin-block-end`), `--tome-campaign-tier-price-leading` (`--tome-type-leading-tight`). - Wall buttons: `--tome-campaign-tier-btn-radius` (`--tome-radius-md`), `--tome-campaign-tier-btn-ghost-rule` (`45%`, the ghost border's share of `currentColor`). - Board: `--tome-campaign-tier-row-cta-gap` (`0.5rem`); at 640px or less `--tome-campaign-tier-row-cta-direction-narrow` (`column`), `-row-cta-align-narrow` (`flex-start`) and `-row-cta-gap-narrow` (`0.5rem`); `--tome-campaign-tier-quiet-rule` (`1px solid` accent ink at 60%, a `border` shorthand); `--tome-campaign-tier-desc-gap` (`0.3rem`) and `--tome-campaign-tier-hand-gap` (`0.4rem`). The README's token tables list every token with its fallback.

  • b452c7b: Campaign Hero's Counter and Night Launch variants now stack on narrow screens, and Campaign Tiers gains stacked Wall seams plus Wall and Board tokens. The hero's notes and the Board hand note also share four new face tokens. Visible change on a default render: - **Hero narrow grid (fix).** Below 1024px the Counter and Night Launch heroes now stack into one column like Ledger. Their two-column rules (specificity 0,3,0) outranked the single-column rule (0,1,0), so both kept two columns at phone width; the variant splits now apply from 1024px only. From 1024px nothing changes. - **Stacked Wall seams (fix).** At 767px or less, Wall cells drop their right-hand seam and take a rule under each cell but the last, in the wall rule color (`--tome-campaign-tier-wall-rule`). Before, stacked cells kept a right seam and had nothing between them. - **Wall buttons are block boxes.** `.btn` is `display: block` (it was an `inline-block` at full width), so the line-box strut under the button, a few px, is gone and each cell ends that much sooner. The button's size and label do not change. - **Wall button color rules carry two classes** (`.cta .btnSolid`, `.cta .btnGhost`), like the Board's `.rowCta .quiet`, so a site's `:root a { color: inherit }` (0,1,1) no longer repaints the solid button's label or the ghost's ink. With no such site rule the colors are unchanged. No other change until a site sets a token (each falls back to today's value): - Hero figure placeholder: `--tome-campaign-placeholder-pad` (`--tome-space-md`), `--tome-campaign-placeholder-bg` (`--tome-color-surface-tint`), `--tome-campaign-placeholder-border` (`1px dashed` accent ink at 45%). - Hand-note face, shared by the hero's CTA note and hand note and the Tiers Board hand note: `--tome-campaign-hand-family` (`--tome-type-serif`), `--tome-campaign-hand-style` (`italic`), `--tome-campaign-hand-weight` (hero `--tome-type-weight-medium`, Board `inherit`), `--tome-campaign-hand-ink` (accent ink at 85%). - Wall cell rhythm: `--tome-campaign-tier-cell-gap` (`1rem`), `--tome-campaign-tier-name-gap`, `-price-gap` and `-feats-gap` (`0`, each a `margin-block-end`), `--tome-campaign-tier-price-leading` (`--tome-type-leading-tight`). - Wall buttons: `--tome-campaign-tier-btn-radius` (`--tome-radius-md`), `--tome-campaign-tier-btn-ghost-rule` (`45%`, the ghost border's share of `currentColor`). - Board: `--tome-campaign-tier-row-cta-gap` (`0.5rem`); at 640px or less `--tome-campaign-tier-row-cta-direction-narrow` (`column`), `-row-cta-align-narrow` (`flex-start`) and `-row-cta-gap-narrow` (`0.5rem`); `--tome-campaign-tier-quiet-rule` (`1px solid` accent ink at 60%, a `border` shorthand); `--tome-campaign-tier-desc-gap` (`0.3rem`) and `--tome-campaign-tier-hand-gap` (`0.4rem`). The README's token tables list every token with its fallback.
v0.5.1patch

6047b25: Campaign Hero, Count and Tiers gain tokens for their placeholder label, CTA labels, on-accent ink, figure leading and tier wall and board, plus five small stock changes listed below. Visible change on a default render: - **Night Launch hero without live figures.** The monument is still left out, but its column now stays: the main column keeps its 1.4fr/1fr share of the band from 1024px up, as it has with figures, instead of stretching across the whole band. Below 1024px nothing changes. - **A goal of 0 reads 0%.** When the source supplies a pledged total and a goal of 0, Campaign Count and the Night Launch monument now show the bar (empty) and the 0% numeral, and a `{percent}` caption fills with `0`. Before, the bar and the caption were hidden. A missing goal still hides both. - **Counts print plain.** Backers, days left and the "DAY N OF M" locator print without thousands grouping (`1204`, not `1,204`), in the count row, the tally, the monument caption, the close numeral and `{backers}` / `{daysLeft}` / `{dayNumber}` / `{totalDays}` captions. Money keeps its grouping (`$18,400`). `formatCount` returns the plain count. - **A tier without a title still renders.** Campaign Tiers used to drop any tier row with no title. A row is now kept when it carries any content (title, price label, description, hand note, CTA label or a feature), so a wall keeps its column and a board its numbered row. A completely empty row is still dropped. - **Board CTA column.** The price and CTA link in a Board row are right-aligned text as well as right-aligned boxes (left-aligned on narrow screens, where the column moves under the row), so a label that wraps lines up with the column's edge. The CTA link's rule now has two-class specificity (`.rowCta .quiet`), so its accent color wins over a class a site's link adapter puts on the anchor; with the stock link the color is unchanged. No other change until a site sets a token (each falls back to today's value): - Hero: `--tome-campaign-placeholder-label-size` (falls back to `--tome-campaign-label-size`) and `--tome-campaign-placeholder-ink` (`currentColor` at 60%) for the figure placeholder label. - Hero and Close: `--tome-campaign-cta-label-size` (`--tome-type-size-xs`) for the CTA labels. - Hero, Close and Tiers: `--tome-campaign-on-accent`, the label ink on an accent fill (solid CTAs, the tier solid button and flag), read before the band's `--blk-on-accent-ink`, then `--tome-color-background`. The band sets its companion inline on the root, so this is how a site fixes that ink. - Count: `--tome-campaign-count-figure-leading` (`--tome-type-leading-tight`) for the figure-row numerals. - Tiers Wall: `--tome-campaign-tier-wall-rule`, `-cell-pad`, `-pledge-display`, `-pledge-gap`, `-pledge-tracking`, `-pledge-weight`, `-pledge-ink`, `-pledge-opacity`, `-feature-gap`, `-feature-pad`, `-feature-rule`, `-feature-ink`, `-flag-tracking`, `-flag-radius`, `-flag-pad-block` (all `--tome-campaign-tier-*`). - Tiers Board: `--tome-campaign-tier-board-border`, `-board-radius`, `-board-overflow`, `-row-pad`, `-row-gap`, `-row-rule`, `-numeral-leading`, `-numeral-stroke-accent`, `-numeral-stroke-strength`, `-desc-measure`, `-quiet-size` (all `--tome-campaign-tier-*`). The README's token tables list every token with its fallback.

  • 6047b25: Campaign Hero, Count and Tiers gain tokens for their placeholder label, CTA labels, on-accent ink, figure leading and tier wall and board, plus five small stock changes listed below. Visible change on a default render: - **Night Launch hero without live figures.** The monument is still left out, but its column now stays: the main column keeps its 1.4fr/1fr share of the band from 1024px up, as it has with figures, instead of stretching across the whole band. Below 1024px nothing changes. - **A goal of 0 reads 0%.** When the source supplies a pledged total and a goal of 0, Campaign Count and the Night Launch monument now show the bar (empty) and the 0% numeral, and a `{percent}` caption fills with `0`. Before, the bar and the caption were hidden. A missing goal still hides both. - **Counts print plain.** Backers, days left and the "DAY N OF M" locator print without thousands grouping (`1204`, not `1,204`), in the count row, the tally, the monument caption, the close numeral and `{backers}` / `{daysLeft}` / `{dayNumber}` / `{totalDays}` captions. Money keeps its grouping (`$18,400`). `formatCount` returns the plain count. - **A tier without a title still renders.** Campaign Tiers used to drop any tier row with no title. A row is now kept when it carries any content (title, price label, description, hand note, CTA label or a feature), so a wall keeps its column and a board its numbered row. A completely empty row is still dropped. - **Board CTA column.** The price and CTA link in a Board row are right-aligned text as well as right-aligned boxes (left-aligned on narrow screens, where the column moves under the row), so a label that wraps lines up with the column's edge. The CTA link's rule now has two-class specificity (`.rowCta .quiet`), so its accent color wins over a class a site's link adapter puts on the anchor; with the stock link the color is unchanged. No other change until a site sets a token (each falls back to today's value): - Hero: `--tome-campaign-placeholder-label-size` (falls back to `--tome-campaign-label-size`) and `--tome-campaign-placeholder-ink` (`currentColor` at 60%) for the figure placeholder label. - Hero and Close: `--tome-campaign-cta-label-size` (`--tome-type-size-xs`) for the CTA labels. - Hero, Close and Tiers: `--tome-campaign-on-accent`, the label ink on an accent fill (solid CTAs, the tier solid button and flag), read before the band's `--blk-on-accent-ink`, then `--tome-color-background`. The band sets its companion inline on the root, so this is how a site fixes that ink. - Count: `--tome-campaign-count-figure-leading` (`--tome-type-leading-tight`) for the figure-row numerals. - Tiers Wall: `--tome-campaign-tier-wall-rule`, `-cell-pad`, `-pledge-display`, `-pledge-gap`, `-pledge-tracking`, `-pledge-weight`, `-pledge-ink`, `-pledge-opacity`, `-feature-gap`, `-feature-pad`, `-feature-rule`, `-feature-ink`, `-flag-tracking`, `-flag-radius`, `-flag-pad-block` (all `--tome-campaign-tier-*`). - Tiers Board: `--tome-campaign-tier-board-border`, `-board-radius`, `-board-overflow`, `-row-pad`, `-row-gap`, `-row-rule`, `-numeral-leading`, `-numeral-stroke-accent`, `-numeral-stroke-strength`, `-desc-measure`, `-quiet-size` (all `--tome-campaign-tier-*`). The README's token tables list every token with its fallback.
v0.5.0minor

709cd6d: **BREAKING:** Campaign bands now run edge to edge of the page, the count block paints a band, and the blocks gain size tokens, fill-mode figure images and an opt-in entrance reveal. What moves: every campaign block's root now spans the full row it is placed in instead of the content column. Inside tome-ui's page grid (or a `RenderBlocks` wrapper spanning the full row) each band's background now reaches both edges of the page, where it used to stop at the content gutters and show the page color either side. The content inside each band stays where it was, on the content column. A block placed in a wrapper that spans only the content column keeps its current width. `campaign-count` used to paint no background; it now paints a `surface-tint` band, edge to edge like the others. **Migration:** raise `@wabbit/tome-blocks-house` to `0.8.4` or later; the peer range is now `>=0.8.4 <1.0.0` (the bands use its `LaneGrid`, the reveal its `animateInView` options and group planner, and the figure its `fillMediaOptions`). No content or schema change. If your site styled a campaign root's `grid-column` or painted the gutters beside a band, remove that styling. To keep a band on the content column only, place the block in a wrapper that spans the content column. Also in this release, all off or unchanged by default: - **Entrance reveal (opt-in).** Pass `context={{ campaignReveal: true }}` (or `{ duration, stagger, ease, start, rise }`) to `RenderBlocks` and each block fades its parts in as it scrolls into view: the hero's main column then its figure or monument, the count's strip, figures, bar and rule line one after another, the tiers' strip then the wall or board, and the terms and close as one piece. Off by default, so a render without the key has no motion and the same markup as before. Content shows in full with JavaScript off and under reduced motion. `readCampaignReveal`, `CAMPAIGN_REVEAL_CONTEXT_KEY` and the `CampaignRevealOptions` and `CampaignRenderContext` types are exported from `./render`. - **Size tokens.** Twenty-one `--tome-campaign-*` size and type properties (headline size, tracking and leading, count numerals, tier name, price and numeral, close body, terms width, mono label size, and more; the README lists them) each fall back to the value they replace, so a site that sets none renders as before. Mono labels read the `xxs` type step, the same size as `xs` on tome-ui's default scale. - **Night wash.** The decorative wash on the Night Launch hero and the Settle close reads `--tome-campaign-night-wash` first, so a site can hold its second color steady across themes; unset, it is the primary color as before. - **Figure image.** The hero's figure asks the site's media adapter for fill mode with a `sizes` hint, so a wrapping adapter (such as one built on `next/image`) fills and crops the figure window. The built-in adapter renders as before. - **Per-theme bands.** A tiers or terms background set per theme now paints its light and dark values under tome-ui's `data-theme`. - **Band tone.** Each root carries `data-band-tone` (`light` or `dark`) when a band is set. The pack still carries no default copy: terms, section-header labels, count labels and tier button text start blank, and a tier with no button label shows no button. The README lists what each block shows without them.

  • 709cd6d: **BREAKING:** Campaign bands now run edge to edge of the page, the count block paints a band, and the blocks gain size tokens, fill-mode figure images and an opt-in entrance reveal. What moves: every campaign block's root now spans the full row it is placed in instead of the content column. Inside tome-ui's page grid (or a `RenderBlocks` wrapper spanning the full row) each band's background now reaches both edges of the page, where it used to stop at the content gutters and show the page color either side. The content inside each band stays where it was, on the content column. A block placed in a wrapper that spans only the content column keeps its current width. `campaign-count` used to paint no background; it now paints a `surface-tint` band, edge to edge like the others. **Migration:** raise `@wabbit/tome-blocks-house` to `0.8.4` or later; the peer range is now `>=0.8.4 <1.0.0` (the bands use its `LaneGrid`, the reveal its `animateInView` options and group planner, and the figure its `fillMediaOptions`). No content or schema change. If your site styled a campaign root's `grid-column` or painted the gutters beside a band, remove that styling. To keep a band on the content column only, place the block in a wrapper that spans the content column. Also in this release, all off or unchanged by default: - **Entrance reveal (opt-in).** Pass `context={{ campaignReveal: true }}` (or `{ duration, stagger, ease, start, rise }`) to `RenderBlocks` and each block fades its parts in as it scrolls into view: the hero's main column then its figure or monument, the count's strip, figures, bar and rule line one after another, the tiers' strip then the wall or board, and the terms and close as one piece. Off by default, so a render without the key has no motion and the same markup as before. Content shows in full with JavaScript off and under reduced motion. `readCampaignReveal`, `CAMPAIGN_REVEAL_CONTEXT_KEY` and the `CampaignRevealOptions` and `CampaignRenderContext` types are exported from `./render`. - **Size tokens.** Twenty-one `--tome-campaign-*` size and type properties (headline size, tracking and leading, count numerals, tier name, price and numeral, close body, terms width, mono label size, and more; the README lists them) each fall back to the value they replace, so a site that sets none renders as before. Mono labels read the `xxs` type step, the same size as `xs` on tome-ui's default scale. - **Night wash.** The decorative wash on the Night Launch hero and the Settle close reads `--tome-campaign-night-wash` first, so a site can hold its second color steady across themes; unset, it is the primary color as before. - **Figure image.** The hero's figure asks the site's media adapter for fill mode with a `sizes` hint, so a wrapping adapter (such as one built on `next/image`) fills and crops the figure window. The built-in adapter renders as before. - **Per-theme bands.** A tiers or terms background set per theme now paints its light and dark values under tome-ui's `data-theme`. - **Band tone.** Each root carries `data-band-tone` (`light` or `dark`) when a band is set. The pack still carries no default copy: terms, section-header labels, count labels and tier button text start blank, and a tier with no button label shows no button. The README lists what each block shows without them.
v0.4.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.4.0minor

28cfbd7: **BREAKING:** Campaign figures (pledged, goal, backers, days left, percent) now come from your site's campaign source at render, and typed numbers are no longer shown. What stops rendering: the typed `pledged`, `goal`, `backers`, `daysLeft`, `dayNumber` and `totalDays` numbers on `campaign-count`, `campaign-hero` (Night Launch) and `campaign-close` (Monument). Without a source, `campaign-count` renders nothing, the Night Launch hero drops its percent monument (the main column takes the full band and the caveat note moves under the CTA), and the Monument close shows its CTA without the days-left numeral. A figure the source does not supply is left out, and a caption whose `{token}` has no figure is hidden rather than shown with a gap. Nothing ever renders as a zero or a placeholder. **Migration:** set the new **Campaign** field (`campaignRef`) on each block to the slug or id your data uses, then render with the new `./server` subpath, spread after the static renderers: `{ ...renderers, ...createHydratedRenderers({ getCampaignFigures }) }` from `@wabbit/tome-blocks-campaign-pack/server`. `getCampaignFigures(campaignRef)` returns `{ pledgedCents, goalCents, backers, daysLeft, dayNumber, totalDays }` (any subset; money in cents) or `null`, runs on the server, and is called once per campaign per request. The old typed fields stay in the schema so no stored data is lost; they are labeled deprecated, hidden on blocks that hold no value, and no longer required, so generated types mark them optional. The pack still installs and renders on a site with no campaign data.

  • 28cfbd7: **BREAKING:** Campaign figures (pledged, goal, backers, days left, percent) now come from your site's campaign source at render, and typed numbers are no longer shown. What stops rendering: the typed `pledged`, `goal`, `backers`, `daysLeft`, `dayNumber` and `totalDays` numbers on `campaign-count`, `campaign-hero` (Night Launch) and `campaign-close` (Monument). Without a source, `campaign-count` renders nothing, the Night Launch hero drops its percent monument (the main column takes the full band and the caveat note moves under the CTA), and the Monument close shows its CTA without the days-left numeral. A figure the source does not supply is left out, and a caption whose `{token}` has no figure is hidden rather than shown with a gap. Nothing ever renders as a zero or a placeholder. **Migration:** set the new **Campaign** field (`campaignRef`) on each block to the slug or id your data uses, then render with the new `./server` subpath, spread after the static renderers: `{ ...renderers, ...createHydratedRenderers({ getCampaignFigures }) }` from `@wabbit/tome-blocks-campaign-pack/server`. `getCampaignFigures(campaignRef)` returns `{ pledgedCents, goalCents, backers, daysLeft, dayNumber, totalDays }` (any subset; money in cents) or `null`, runs on the server, and is called once per campaign per request. The old typed fields stay in the schema so no stored data is lost; they are labeled deprecated, hidden on blocks that hold no value, and no longer required, so generated types mark them optional. The pack still installs and renders on a site with no campaign data.
v0.3.2patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.3.1patch

12b70a9: CampaignHero: the figure image now carries its media doc's alt text instead of a hard-coded empty alt.

  • 12b70a9: CampaignHero: the figure image now carries its media doc's alt text instead of a hard-coded empty alt.
  • 8c84e70: Solid CTAs and tags filled with the accent text ink now use the page color as their label instead of `on-accent`, which pairs with the accent fill and measured about 2:1 on the text ink. campaign-hero, campaign-close and campaign-tiers also read the band's accent ink and its label on solid-dark bands.
v0.3.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 5 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 5 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - No client renderers. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.2.2patch

e68b541: CSS files are copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook, whose output the type-declaration phase can remove.

  • e68b541: CSS files are copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook, whose output the type-declaration phase can remove.
v0.2.1patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.
v0.2.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.1.4patch

Updated dependencies [e044594] - @wabbit/tome-blocks-core@0.16.6

  • Updated dependencies [e044594] - @wabbit/tome-blocks-core@0.16.6
v0.1.3patch

Updated dependencies [a2f2dfa] - @wabbit/tome-blocks-house@0.3.0

  • Updated dependencies [a2f2dfa] - @wabbit/tome-blocks-house@0.3.0
v0.1.2patch

Updated dependencies [7850b7a] - @wabbit/tome-blocks-house@0.2.0

  • Updated dependencies [7850b7a] - @wabbit/tome-blocks-house@0.2.0
v0.1.1patch

f49947a: Demo props now carry `_variant`, so gallery thumbs render the requested variant (the `monument` close rendered blank; hero/count/tiers variants rendered their defaults).

  • f49947a: Demo props now carry `_variant`, so gallery thumbs render the requested variant (the `monument` close rendered blank; hero/count/tiers variants rendered their defaults).

Blocks Cinema Pack

v0.4.10
v0.4.10patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.4.9patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.4.8patch

8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The cinema-feature `ratio` was a select whose values (`21:9`, `16:9`) contain a colon, which made Payload's GraphQL schema build throw and `/api/graphql` return 500 on any site using the block. It is now a text field with the same default (`21:9`) that accepts exactly `21:9`, `16:9` or empty; existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it.

  • 8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The cinema-feature `ratio` was a select whose values (`21:9`, `16:9`) contain a colon, which made Payload's GraphQL schema build throw and `/api/graphql` return 500 on any site using the block. It is now a text field with the same default (`21:9`) that accepts exactly `21:9`, `16:9` or empty; existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it.
v0.4.7patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.4.6patch

1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.

  • 1698a25: Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge. Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on `content-start / content-end`; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new `.inner` element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended. Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge. Editorial Footnotes runs from `content-start` across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines. Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (`--tome-grid-padding`); set `--tome-cinema-scrub-inset` to override it (for example `0px` when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.
v0.4.5patch

d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.

  • d10e467: Muted labels and captions now meet WCAG AA contrast on light surfaces. - Labels, captions, column heads, struck and absent values, and placeholder labels that mixed their ink at 40–60% now mix it at 70% (63 rules). Small muted ink below about 61% fails 4.5:1 on cream and tinted light surfaces; a live CompareLedger row label measured 3.92:1 at 55%. - This includes the house `mono-label-muted` and `placeholder-label` mixins, and the defaults of `--tome-dossier-thumb-placeholder-ink`, `--tome-campaign-placeholder-ink` and `--tome-campaign-tier-pledge-ink`. Set those properties to keep the old ink. - Decorative ink is unchanged: outline numerals, scrims, corner brackets and the EvidencePlate sources separator. - Muted text reads slightly darker on every surface.
v0.4.4patch

fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.

  • fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.
v0.4.3patch

b452c7b: Testimonial Rail's edge inset and edge fade become tokens; Media Panel's CTA paints above the panel image; Cinema Feature's CTA lines up with the frame when a site insets it. No change until a site sets the token: - `--tome-cinema-rail-edge-inset` (default `calc(36 * var(--tome-cinema-px, 0.0625rem))`) and `--tome-cinema-rail-edge-inset-narrow` (default `calc(18 * var(--tome-cinema-px, 0.0625rem))`, at 40em or less): Testimonial Rail's track side inset. The status line and the CTA follow it; `--tome-cinema-rail-cta-inset` still overrides the CTA and now falls back to these tokens. - `--tome-cinema-rail-fade` (default `calc(56 * var(--tome-cinema-px, 0.0625rem))`): the width of the rail's edge fade on each side. - Cinema Feature's CTA now reads `--tome-cinema-feature-inset` (default `0px`) as its start padding. The CTA renders after the block's inner column, so `.inner`'s padding never reached it; with the token set, the CTA now starts on the same line as the frame and caption. Unset, nothing moves. The stylesheet comment that said the CTA sits inside `.inner` is corrected. Visible change on a default render: - **Media Panel's CTA shows.** The CTA is now `position: relative; z-index: 2`, so it paints above the absolutely positioned image (z-index 0) and scrim (z-index 1) instead of under them. A CTA that the image covered is now visible; its placement, type and color are unchanged.

  • b452c7b: Testimonial Rail's edge inset and edge fade become tokens; Media Panel's CTA paints above the panel image; Cinema Feature's CTA lines up with the frame when a site insets it. No change until a site sets the token: - `--tome-cinema-rail-edge-inset` (default `calc(36 * var(--tome-cinema-px, 0.0625rem))`) and `--tome-cinema-rail-edge-inset-narrow` (default `calc(18 * var(--tome-cinema-px, 0.0625rem))`, at 40em or less): Testimonial Rail's track side inset. The status line and the CTA follow it; `--tome-cinema-rail-cta-inset` still overrides the CTA and now falls back to these tokens. - `--tome-cinema-rail-fade` (default `calc(56 * var(--tome-cinema-px, 0.0625rem))`): the width of the rail's edge fade on each side. - Cinema Feature's CTA now reads `--tome-cinema-feature-inset` (default `0px`) as its start padding. The CTA renders after the block's inner column, so `.inner`'s padding never reached it; with the token set, the CTA now starts on the same line as the frame and caption. Unset, nothing moves. The stylesheet comment that said the CTA sits inside `.inner` is corrected. Visible change on a default render: - **Media Panel's CTA shows.** The CTA is now `position: relative; z-index: 2`, so it paints above the absolutely positioned image (z-index 0) and scrim (z-index 1) instead of under them. A CTA that the image covered is now visible; its placement, type and color are unchanged.
v0.4.2patch

eee4d87: Seven block CTAs read the house editorial CTA register, two gain inset tokens, and five blocks gain spacing tokens. **CTA register.** The CTAs of Scrub Story, Chaptered Walkthrough, Video Wall, Testimonial Rail, Trigger Tile, Cinema Feature and Media Panel read `--tome-house-cta-editorial-family`, `-size`, `-weight`, `-tracking`, `-transform`, `-margin` and `-color` from `@wabbit/tome-blocks-house`, each falling back to that block's current value: Scrub Story and Chaptered Walkthrough set no type and keep a `--tome-space-md` top margin; Video Wall, Testimonial Rail, Trigger Tile and Cinema Feature keep the mono face, `--tome-text-xs`, the editorial tracking, uppercase and a `--tome-space-lg` top margin; Media Panel keeps its centered, underlined link and its type and color. On a grid parent each (Media Panel excepted) takes `--tome-house-cta-cols` and starts its row. Ambient Band's bordered button is not on the register. No change until a site sets the token: - `--tome-cinema-wall-cta-inset` (default `clamp(1.5rem, 4vw, 3rem)`) and `--tome-cinema-rail-cta-inset` (default the track's inset, `calc(36 * var(--tome-cinema-px))`, 18 below 40em; one value replaces both): the CTA's start inset, so a site that places the block full-bleed can start it on the content column with `var(--tome-grid-padding)`. - `--tome-cinema-ambient-cta-pad-block` (default `var(--tome-space-4, 0.85rem)`): Ambient Band's button top and bottom padding. - `--tome-cinema-chapter-gap` (default `var(--tome-space-sm, 0.65rem)`) and `--tome-cinema-chapter-progress-gap` (default `var(--tome-space-sm, 0.55rem)`): Chaptered Walkthrough's chapter row gap and the space above each progress line. - `--tome-cinema-walkthrough-below-margin` / `-below-pad` (defaults `var(--tome-space-8, 2rem)` / `var(--tome-space-5, 1.25rem)`) and `--tome-cinema-walkthrough-below-margin-narrow` / `-below-pad-narrow` (defaults `var(--tome-space-6, 1.5rem)` / `var(--tome-space-4, 1rem)`, at 640px or less): the space above and under the below-player copy's hairline. - `--tome-cinema-rail-placeholder-pad` (default `var(--tome-space-lg, 1rem)`): the label padding in an empty Testimonial Rail card. - `--tome-cinema-spread-media-width-narrow` (default `100%`): the width of Video Spread's video and poster inside the stacked panel (below 64em), anchored at its start edge and clipped by the panel. `max(100%, 35.5556rem)` gives the wider, left-anchored crop of a panel sized from its 20rem floor, without the panel overflowing the column. Visible change on a default render: - **Cinema Feature's CTA** no longer stretches across the block's column: the link's box (its clickable and focus area) now ends at its label. The label itself does not move. - **Link color.** Scrub Story's, Chaptered Walkthrough's, Video Wall's, Testimonial Rail's, Trigger Tile's and Cinema Feature's CTAs now declare `var(--tome-color-primary)`, the color tome-ui's base stylesheet gives every link, so on a tome-ui site they are unchanged. On a site whose own element-level `a { color }` rule colored them differently, they now take `--tome-color-primary` unless the site sets `--tome-house-cta-editorial-color`; a link rule with a class or `:root` in it still wins as before.

  • eee4d87: Seven block CTAs read the house editorial CTA register, two gain inset tokens, and five blocks gain spacing tokens. **CTA register.** The CTAs of Scrub Story, Chaptered Walkthrough, Video Wall, Testimonial Rail, Trigger Tile, Cinema Feature and Media Panel read `--tome-house-cta-editorial-family`, `-size`, `-weight`, `-tracking`, `-transform`, `-margin` and `-color` from `@wabbit/tome-blocks-house`, each falling back to that block's current value: Scrub Story and Chaptered Walkthrough set no type and keep a `--tome-space-md` top margin; Video Wall, Testimonial Rail, Trigger Tile and Cinema Feature keep the mono face, `--tome-text-xs`, the editorial tracking, uppercase and a `--tome-space-lg` top margin; Media Panel keeps its centered, underlined link and its type and color. On a grid parent each (Media Panel excepted) takes `--tome-house-cta-cols` and starts its row. Ambient Band's bordered button is not on the register. No change until a site sets the token: - `--tome-cinema-wall-cta-inset` (default `clamp(1.5rem, 4vw, 3rem)`) and `--tome-cinema-rail-cta-inset` (default the track's inset, `calc(36 * var(--tome-cinema-px))`, 18 below 40em; one value replaces both): the CTA's start inset, so a site that places the block full-bleed can start it on the content column with `var(--tome-grid-padding)`. - `--tome-cinema-ambient-cta-pad-block` (default `var(--tome-space-4, 0.85rem)`): Ambient Band's button top and bottom padding. - `--tome-cinema-chapter-gap` (default `var(--tome-space-sm, 0.65rem)`) and `--tome-cinema-chapter-progress-gap` (default `var(--tome-space-sm, 0.55rem)`): Chaptered Walkthrough's chapter row gap and the space above each progress line. - `--tome-cinema-walkthrough-below-margin` / `-below-pad` (defaults `var(--tome-space-8, 2rem)` / `var(--tome-space-5, 1.25rem)`) and `--tome-cinema-walkthrough-below-margin-narrow` / `-below-pad-narrow` (defaults `var(--tome-space-6, 1.5rem)` / `var(--tome-space-4, 1rem)`, at 640px or less): the space above and under the below-player copy's hairline. - `--tome-cinema-rail-placeholder-pad` (default `var(--tome-space-lg, 1rem)`): the label padding in an empty Testimonial Rail card. - `--tome-cinema-spread-media-width-narrow` (default `100%`): the width of Video Spread's video and poster inside the stacked panel (below 64em), anchored at its start edge and clipped by the panel. `max(100%, 35.5556rem)` gives the wider, left-anchored crop of a panel sized from its 20rem floor, without the panel overflowing the column. Visible change on a default render: - **Cinema Feature's CTA** no longer stretches across the block's column: the link's box (its clickable and focus area) now ends at its label. The label itself does not move. - **Link color.** Scrub Story's, Chaptered Walkthrough's, Video Wall's, Testimonial Rail's, Trigger Tile's and Cinema Feature's CTAs now declare `var(--tome-color-primary)`, the color tome-ui's base stylesheet gives every link, so on a tome-ui site they are unchanged. On a site whose own element-level `a { color }` rule colored them differently, they now take `--tome-color-primary` unless the site sets `--tome-house-cta-editorial-color`; a link rule with a class or `:root` in it still wins as before.
v0.4.1patch

7c94229: Pull Interlude keeps its line inside the page gutter on narrow screens, and four blocks gain tokens for their video wells, scrim and readout. Visible change on a default render: - **Pull Interlude, below the width where its 34ch line fits.** The block now renders a bleeding lane grid (`LaneGrid` from `@wabbit/tome-blocks-house`) with the pull-line on the content column, as the line on the page grid always intended. On a phone the line used to run the whole band, edge to edge; it now stops at the side gutter (`--tome-grid-padding`, the page grid's padding column when the block sits on the page grid). Where the 34ch line fits inside the content column, typically from tablet width up, nothing moves. In a block wrapper narrower than the content column the line spans the wrapper with no gutter, as before. The band, art layer, scrim and tag are unchanged, and the root still spans its whole host row. No other change until a site sets a token: - `--tome-cinema-video-well` (default `var(--tome-color-surface-solid-dark)`): the well behind a playing video, the color of its letterbox bars, in Video Wall (tile video, lightbox video and embed), Testimonial Rail (card video and its wrapper), Cinema Feature (video and its wrapper; the frame stays solid-dark) and Trigger Tile (lightbox video). - `--tome-cinema-ambient-scrim-base` (default `var(--tome-color-surface-solid-dark)`): the color Ambient Band's scrim gradient mixes from; the scrim select still sets its strength. - `--tome-cinema-readout-gap` (default `var(--tome-space-lg, 1.25rem)`): the gap inside Scrub Story's pinned readout. - Testimonial Rail's edge-fade mask (56), track inset (36, 18 below 40em), status line and CTA insets are now stated in `--tome-cinema-px` units, so they are the same 3.5rem, 2.25rem and 1.125rem at the default unit and follow a site that sets the unit.

  • 7c94229: Pull Interlude keeps its line inside the page gutter on narrow screens, and four blocks gain tokens for their video wells, scrim and readout. Visible change on a default render: - **Pull Interlude, below the width where its 34ch line fits.** The block now renders a bleeding lane grid (`LaneGrid` from `@wabbit/tome-blocks-house`) with the pull-line on the content column, as the line on the page grid always intended. On a phone the line used to run the whole band, edge to edge; it now stops at the side gutter (`--tome-grid-padding`, the page grid's padding column when the block sits on the page grid). Where the 34ch line fits inside the content column, typically from tablet width up, nothing moves. In a block wrapper narrower than the content column the line spans the wrapper with no gutter, as before. The band, art layer, scrim and tag are unchanged, and the root still spans its whole host row. No other change until a site sets a token: - `--tome-cinema-video-well` (default `var(--tome-color-surface-solid-dark)`): the well behind a playing video, the color of its letterbox bars, in Video Wall (tile video, lightbox video and embed), Testimonial Rail (card video and its wrapper), Cinema Feature (video and its wrapper; the frame stays solid-dark) and Trigger Tile (lightbox video). - `--tome-cinema-ambient-scrim-base` (default `var(--tome-color-surface-solid-dark)`): the color Ambient Band's scrim gradient mixes from; the scrim select still sets its strength. - `--tome-cinema-readout-gap` (default `var(--tome-space-lg, 1.25rem)`): the gap inside Scrub Story's pinned readout. - Testimonial Rail's edge-fade mask (56), track inset (36, 18 below 40em), status line and CTA insets are now stated in `--tome-cinema-px` units, so they are the same 3.5rem, 2.25rem and 1.125rem at the default unit and follow a site that sets the unit.
v0.4.0minor

be6c393: **BREAKING:** Statement Band, Scene Plate and Scene Caption now lay out on the page's reading lanes, so their text moves on the page; background images ask the media adapter for fill mode. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.0 or later (the peer range is now `>=0.8.0 <1.0.0`; these blocks use its lane grid). No content or config changes. There is no switch back to the old fixed-width layout. What you will see move, from 1024px wide (below that every part takes the content column, inset by the page's grid gutter instead of the block's own side padding): - **Statement Band.** The serif statement and support line sit in the reading column instead of a 46rem box, and the display face runs the content column instead of a 64rem box, so on a wide screen a statement takes fewer lines. The section strip runs the content column instead of the full band. - **Scene Plate.** The copy sits on the content column instead of a 60rem box; on the centered `plate` variant it is centered as before, and the `cinema` variant now starts at the content edge. Left and right seated panels split the content column at the page's center line, and the center panel sits on the prose lane. The CTA, when set, sits under the copy above the scrim. The heading's `*accent*` phrase is set without the heading's tight tracking. - **Scene Caption.** The caption runs from the content edge to the end of the reading column instead of a 44ch box at the band's edge; its text keeps its 34ch measure. Other visible changes: - **Scene Plate paints its band.** A plate with no stored background now shows its dark band behind the scene and copy (before, only the scrim used it). A stored `inherit` still paints nothing. - **Scene Caption placeholder.** With no art, the pink wash reads the band's primary ink first, so on the default dark band it is the lighter on-dark primary. Additive: - **Images that fill a box.** Scene Plate's scene, Scene Caption's and Pull Interlude's art, Media Panel's image, and Cinema Feature's and Ambient Band's posters pass the media adapter `fill: true`, an `imgClassName` and `sizes: '100vw'`, so an adapter that wraps its image can fill the box with a cover crop. The built-in adapter renders the same `<img>` with a second class and a `sizes` attribute; it looks the same. - **`--tome-cinema-statement-display-tracking`** sets Statement Band's display-face tracking (default `var(--tome-type-tracking-tight)`) without moving the serif face. - **`--tome-cinema-caption-duration`** sets Scene Caption's entrance duration on the block (default: the house entrance timing). - Pull Interlude's placeholder washes and Scene Plate's panel kicker and italic read the band companions (`--blk-primary-ink`, `--blk-accent-ink`) before the theme tokens. Nothing in the pack changes for these with the default bands.

  • be6c393: **BREAKING:** Statement Band, Scene Plate and Scene Caption now lay out on the page's reading lanes, so their text moves on the page; background images ask the media adapter for fill mode. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.0 or later (the peer range is now `>=0.8.0 <1.0.0`; these blocks use its lane grid). No content or config changes. There is no switch back to the old fixed-width layout. What you will see move, from 1024px wide (below that every part takes the content column, inset by the page's grid gutter instead of the block's own side padding): - **Statement Band.** The serif statement and support line sit in the reading column instead of a 46rem box, and the display face runs the content column instead of a 64rem box, so on a wide screen a statement takes fewer lines. The section strip runs the content column instead of the full band. - **Scene Plate.** The copy sits on the content column instead of a 60rem box; on the centered `plate` variant it is centered as before, and the `cinema` variant now starts at the content edge. Left and right seated panels split the content column at the page's center line, and the center panel sits on the prose lane. The CTA, when set, sits under the copy above the scrim. The heading's `*accent*` phrase is set without the heading's tight tracking. - **Scene Caption.** The caption runs from the content edge to the end of the reading column instead of a 44ch box at the band's edge; its text keeps its 34ch measure. Other visible changes: - **Scene Plate paints its band.** A plate with no stored background now shows its dark band behind the scene and copy (before, only the scrim used it). A stored `inherit` still paints nothing. - **Scene Caption placeholder.** With no art, the pink wash reads the band's primary ink first, so on the default dark band it is the lighter on-dark primary. Additive: - **Images that fill a box.** Scene Plate's scene, Scene Caption's and Pull Interlude's art, Media Panel's image, and Cinema Feature's and Ambient Band's posters pass the media adapter `fill: true`, an `imgClassName` and `sizes: '100vw'`, so an adapter that wraps its image can fill the box with a cover crop. The built-in adapter renders the same `<img>` with a second class and a `sizes` attribute; it looks the same. - **`--tome-cinema-statement-display-tracking`** sets Statement Band's display-face tracking (default `var(--tome-type-tracking-tight)`) without moving the serif face. - **`--tome-cinema-caption-duration`** sets Scene Caption's entrance duration on the block (default: the house entrance timing). - Pull Interlude's placeholder washes and Scene Plate's panel kicker and italic read the band companions (`--blk-primary-ink`, `--blk-accent-ink`) before the theme tokens. Nothing in the pack changes for these with the default bands.
  • fb10584: **BREAKING:** Needs `@wabbit/tome-blocks-house` 0.8.4 or later; Video Wall, Testimonial Rail and Cinema Feature paint a chosen background; Media Panel's kicker takes the accent color. **Migration:** upgrade `@wabbit/tome-blocks-house` to 0.8.4 or later (the peer range is now `>=0.8.4 <1.0.0`; every image that fills a box now builds its options with that package's `fillMediaOptions`, and the pack's own copy is gone). No content or config changes. What you will see change: - **Media Panel kicker.** The kicker reads a band's accent ink if one is set, else tome-ui's accent ink for the solid-dark surface (`--tome-color-accent-on-solid-dark`), instead of the plain on-dark ink. It is the panel's one accent color, and it stays legible on the dark dock in both themes. The fig label, heading, body and the dock's hairline keep the on-dark ink. - **Video Wall, Testimonial Rail and Cinema Feature paint their band.** A block with a background chosen now shows it behind the wall, rail or frame, and its text takes the band's ink; before, the band's colors were set but nothing painted them. With no background set the root stays transparent, as before. The three roots also carry `data-band-tone`. - **Accents on a dark band.** Chaptered Walkthrough's active chapter, focus ring and progress fill, and Cinema Feature's eyebrow, read the band's accent ink (`--blk-accent-ink`) before `--tome-color-accent-text`. With no background, or a theme-following one, nothing changes; on a fixed dark band the accent is the band's own legible one. - **Scene Plate panel body.** A seated panel whose rich-text body holds only a list (or only linked text) now renders. The emptiness check looks for text at any depth instead of only directly under each top-level block. - **Testimonial Rail names.** The card name sets `font-variation-settings` itself (`normal`), so a page rule that thins every paragraph through the variable-font weight axis no longer thins the name below its 600 weight. Pages without such a rule look the same. Images that fill a box: - Scrub Story's stills (`sizes: '100vw'`, the first still keeps `priority`) and reduced-motion frames (`'(max-width: 768px) 100vw, 60vw'`), and Trigger Tile's uploaded poster (`'(max-width: 768px) 90vw, 65ch'`), pass the media adapter `fill: true`, an `imgClassName` and `sizes`, so an adapter that wraps its image fills the frame with a cover crop. The built-in adapter renders the same `<img>` with a second class and a `sizes` attribute; it looks the same. No change until a site sets a token or prop (each defaults to the value it replaced): - `--tome-cinema-px` (default `0.0625rem`): the unit for Video Wall's play ring and title chip and Testimonial Rail's card widths, which are now stated in pixels times the unit. A site whose root font size is not 16px sets `1px` to keep them at their pixel size. - `--tome-cinema-panel-accent` (Media Panel kicker) and `--tome-cinema-panel-border` (the dock hairline's ink, default the on-dark ink). - `--tome-cinema-scrub-crossfade` (Scrub Story's stills/video crossfade, default `--tome-motion-slow`; the beat change keeps `-slow`) and `--tome-cinema-feature-poster-fade` (Cinema Feature's poster-to-video fade, default `--tome-motion-base`). - `--tome-cinema-feature-inset` (Cinema Feature) and `--tome-cinema-ambient-copy-inset` (Ambient Band's copy), both `0px`: set them to the page grid's side gutter to put the frame or copy on the content column of a full-bleed band. - `--tome-cinema-walkthrough-max-width` (Chaptered Walkthrough, default `72rem`; `none` fills the block). - `--tome-cinema-rail-name-variation` (Testimonial Rail name, default `normal`). - `--tome-cinema-interlude-placeholder-base` (Pull Interlude's empty-art base, default `--tome-color-surface-solid-dark`). - `videoPreload: 'metadata'` on a Chaptered Walkthrough block (a render prop, not an editor field) loads the video's metadata on mount, so the last chapter's length and range use the real duration before playback. The default stays `none`. The looping-video pause button, the play-ring styling and the entrance timers are unchanged.
v0.3.4patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.3.3patch

868c087: The looping video blocks (ambient band, video wall, video spread) now have a visible pause and play button. Pausing stops the video and keeps it stopped when the block scrolls back into view; the ambient band's slow zoom stops with it. The video wall has one button that pauses every looping tile. The button is always visible and reachable by keyboard; reduced-motion visitors, who already get a still poster, see no button. Needs `@wabbit/tome-blocks-house` with `LoopVideoControl`.

  • 868c087: The looping video blocks (ambient band, video wall, video spread) now have a visible pause and play button. Pausing stops the video and keeps it stopped when the block scrolls back into view; the ambient band's slow zoom stops with it. The video wall has one button that pauses every looping tile. The button is always visible and reachable by keyboard; reduced-motion visitors, who already get a still poster, see no button. Needs `@wabbit/tome-blocks-house` with `LoopVideoControl`.
  • d08fc38: The video spread block no longer overflows a phone-width screen when its link label is long. The stacked layout's grid track can now shrink, and the link wraps within its column instead of holding its full text width.
v0.3.2patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.3.1patch

8c84e70: statement-band, scene-plate, scene-caption and video-spread read the band's accent and primary inks from `resolveBackground`, so the accent on their solid-dark plates is readable in light themes. pull-interlude, which is always solid-dark, reads `--tome-color-accent-on-solid-dark` for its strong runs.

  • 8c84e70: statement-band, scene-plate, scene-caption and video-spread read the band's accent and primary inks from `resolveBackground`, so the accent on their solid-dark plates is readable in light themes. pull-interlude, which is always solid-dark, reads `--tome-color-accent-on-solid-dark` for its strong runs.
v0.3.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 13 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - PullInterlude (client) is now a server-safe wrapper around `PullInterlude.client.tsx`, its exported types re-exported. The blocks already split into a server renderer plus a client half (players, motion, engines) keep that split; the client halves import the same `.tome-css` for class names. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 13 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - PullInterlude (client) is now a server-safe wrapper around `PullInterlude.client.tsx`, its exported types re-exported. The blocks already split into a server renderer plus a client half (players, motion, engines) keep that split; the client halves import the same `.tome-css` for class names. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.2.2patch

7862f30: Swaps the plain primary token for the on-solid-dark pairing on text and borders that sit on a dark surface in three packs. `@wabbit/tome-blocks-dossier-pack`: the `evidence-sheet` block's `ledger` treatment now uses the on-solid-dark pairing for its kicker, headline emphasis, body link and row-label text, instead of the plain primary token — that token is a surface-tint fill, not guaranteed legible as text on the block's dark surface. `@wabbit/tome-blocks-cinema-pack`: the `scene-plate` block's seated panel kicker and body emphasis text get the same on-solid-dark pairing, since the panel itself is a partially-opaque dark surface over the scene image. `@wabbit/tome-blocks-catalog-pack`: the `price-table` block's highlighted-tier border now uses the on-solid-dark pairing specifically on the `dark` variant, leaving the default/light variant's border on the plain primary token unchanged.

  • 7862f30: Swaps the plain primary token for the on-solid-dark pairing on text and borders that sit on a dark surface in three packs. `@wabbit/tome-blocks-dossier-pack`: the `evidence-sheet` block's `ledger` treatment now uses the on-solid-dark pairing for its kicker, headline emphasis, body link and row-label text, instead of the plain primary token — that token is a surface-tint fill, not guaranteed legible as text on the block's dark surface. `@wabbit/tome-blocks-cinema-pack`: the `scene-plate` block's seated panel kicker and body emphasis text get the same on-solid-dark pairing, since the panel itself is a partially-opaque dark surface over the scene image. `@wabbit/tome-blocks-catalog-pack`: the `price-table` block's highlighted-tier border now uses the on-solid-dark pairing specifically on the `dark` variant, leaving the default/light variant's border on the plain primary token unchanged.
v0.2.1patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.
v0.2.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.1.3patch

Updated dependencies [e044594] - @wabbit/tome-blocks-core@0.16.6

  • Updated dependencies [e044594] - @wabbit/tome-blocks-core@0.16.6
v0.1.2patch

a2f2dfa: cinema-pack's block configs now load under plain Node — no bundler, no CSS loader. Eight of cinema-pack's block configs (`src/blocks/*/index.ts`) imported `videoSourceFields` from `@wabbit/tome-blocks-house/video`. That entry is a barrel that also re-exports the `'use client'` `EmbedFrame` and its CSS Module. Next.js resolves that CSS; plain Node does not. So any script that loaded cinema-pack's main entry outside a bundler (a Payload CLI command, a seed, a type generator) failed with `ERR_UNKNOWN_FILE_EXTENSION ".css"` before a single block registered. - **blocks-house (minor):** new `./video/fields` export for `videoSourceFields` and its resolvers. It points at the module the package already built and has no CSS on its import graph. `./video` is unchanged, so existing imports keep working. - **cinema-pack (patch):** the eight block configs import from `@wabbit/tome-blocks-house/video/fields`. Render components still use `./video`, because they need `EmbedFrame` and `useHlsVideo`. Verified against the rebuilt dist: importing `@wabbit/tome-blocks-cinema-pack` and calling `register()` under bare Node now registers all 13 blocks. The same import failed with the CSS error on `main`. cinema-pack's smoke test no longer needs a CSS stub loader, and the stub is removed. No consumer changes are needed. wabbit-site-core and tome-starter import only cinema-pack's `./meta`, `./demo`, `./render` and `./render/register` entries, none of which reached the barrel.

  • a2f2dfa: cinema-pack's block configs now load under plain Node — no bundler, no CSS loader. Eight of cinema-pack's block configs (`src/blocks/*/index.ts`) imported `videoSourceFields` from `@wabbit/tome-blocks-house/video`. That entry is a barrel that also re-exports the `'use client'` `EmbedFrame` and its CSS Module. Next.js resolves that CSS; plain Node does not. So any script that loaded cinema-pack's main entry outside a bundler (a Payload CLI command, a seed, a type generator) failed with `ERR_UNKNOWN_FILE_EXTENSION ".css"` before a single block registered. - **blocks-house (minor):** new `./video/fields` export for `videoSourceFields` and its resolvers. It points at the module the package already built and has no CSS on its import graph. `./video` is unchanged, so existing imports keep working. - **cinema-pack (patch):** the eight block configs import from `@wabbit/tome-blocks-house/video/fields`. Render components still use `./video`, because they need `EmbedFrame` and `useHlsVideo`. Verified against the rebuilt dist: importing `@wabbit/tome-blocks-cinema-pack` and calling `register()` under bare Node now registers all 13 blocks. The same import failed with the CSS error on `main`. cinema-pack's smoke test no longer needs a CSS stub loader, and the stub is removed. No consumer changes are needed. wabbit-site-core and tome-starter import only cinema-pack's `./meta`, `./demo`, `./render` and `./render/register` entries, none of which reached the barrel.
  • Updated dependencies [a2f2dfa] - @wabbit/tome-blocks-house@0.3.0
v0.1.1patch

1dcc935: pull-interlude carries a `media` tag so the gallery files it under Media with its siblings instead of a stray "Layout" facet.

  • 1dcc935: pull-interlude carries a `media` tag so the gallery files it under Media with its siblings instead of a stray "Layout" facet.

Blocks Extras

v0.49.7
v0.49.7patch

95b51e3: The bold words in `section-header` Transcript Stage's claim stay readable at small sizes. They used the primary color as is, which can fall under 4.5:1 on a tinted card once the claim drops below large-text size. They now mix in the foreground color.

  • 95b51e3: The bold words in `section-header` Transcript Stage's claim stay readable at small sizes. They used the primary color as is, which can fall under 4.5:1 on a tinted card once the claim drops below large-text size. They now mix in the foreground color.
v0.49.6patch

76fc86a: Code in `section-header` Transcript Split and Transcript Stage stays readable on sites that fill `pre` and `code`. These variants set a text color but no background, so a host's fill could leave the text unreadable. They now clear it.

  • 76fc86a: Code in `section-header` Transcript Split and Transcript Stage stays readable on sites that fill `pre` and `code`. These variants set a text color but no background, so a host's fill could leave the text unreadable. They now clear it.
v0.49.5patch

66b9c81: `section-header` / `product-split`: add `imageStacked`, a one-column page opener. It sets a larger headline, then the caption, then a full-width picture cropped flush at the bottom of the band. It's off by default, so existing pages render as before.

  • 66b9c81: `section-header` / `product-split`: add `imageStacked`, a one-column page opener. It sets a larger headline, then the caption, then a full-width picture cropped flush at the bottom of the band. It's off by default, so existing pages render as before.
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.49.2patch

27139be: Full-width bands on a site with a default column span, and two details the Starter's home page needed. Stop Viewer, Switch Tally, Proof Ledger, Terminal Steps, Layer Stack and Term Rows gain the optional `columnSpan` field. These blocks are full-width bands, but on a site with a default span such as `2 / -2` they stopped at the content lines and their text sat against the band's edge, because the field that moves the block's wrapper did not exist. Set `columnSpan` to `1 / -1`. A stored block with no span renders the same markup. The Command Close underlines the links in its door line, which a site link reset could strip. Proof Ledger sets a run wrapped in backticks in the mono face, in a promise's body and the figure caption, where the backticks used to print. The section header gains an optional `backgroundColor`, which `RenderBlocks` paints on the block's wrapper. `accent-tint` needs a mapping in the site's block backgrounds. On a painted band the Ruled Split drops its top rule.

  • 27139be: Full-width bands on a site with a default column span, and two details the Starter's home page needed. Stop Viewer, Switch Tally, Proof Ledger, Terminal Steps, Layer Stack and Term Rows gain the optional `columnSpan` field. These blocks are full-width bands, but on a site with a default span such as `2 / -2` they stopped at the content lines and their text sat against the band's edge, because the field that moves the block's wrapper did not exist. Set `columnSpan` to `1 / -1`. A stored block with no span renders the same markup. The Command Close underlines the links in its door line, which a site link reset could strip. Proof Ledger sets a run wrapped in backticks in the mono face, in a promise's body and the figure caption, where the backticks used to print. The section header gains an optional `backgroundColor`, which `RenderBlocks` paints on the block's wrapper. `accent-tint` needs a mapping in the site's block backgrounds. On a painted band the Ruled Split drops its top rule.
v0.49.1patch

da36c0e: The section header can now span the full grid, which the Product Split bleed needs to reach the screen edge. The block gains the optional `columnSpan` field the call to action and feature blocks already have. On a site with a default span such as `2 / -2`, the Product Split picture stopped at the content line, because the field that moves the block's wrapper did not exist and a stored value was dropped. Set `columnSpan` to `1 / -1` together with `imageBleed`. A stored section header with no span renders the same markup.

  • da36c0e: The section header can now span the full grid, which the Product Split bleed needs to reach the screen edge. The block gains the optional `columnSpan` field the call to action and feature blocks already have. On a site with a default span such as `2 / -2`, the Product Split picture stopped at the content line, because the field that moves the block's wrapper did not exist and a stored value was dropped. Set `columnSpan` to `1 / -1` together with `imageBleed`. A stored section header with no span renders the same markup.
v0.49.0minor

d08e2ee: The section header gains two variants, Product Split and Transcript Stage, and an optional list of paired rows on Ruled Split. Additive: a stored section header of any existing variant renders the same markup, and a Ruled Split with no rows is byte-identical to before. - `product-split`: copy at the left (title with `*emphasis*`, a rule, a lede, body text, up to two links and an optional list) beside a captioned picture at the right (5/7). Set `imageBleed` to run the picture to the right edge of the screen, cropped flush at the bottom of the band, with the caption above it; leave it off for an inset picture with the caption underneath. Set `titleLevel` to `h1` for the page's opening hero, which also loads the picture first. It is a server component with no entrance animation. - `transcript-stage`: reads the same fields as `transcript-split` (which is unchanged), staged as the headline alone, a large terminal plate beside the lede and copy, then a claim panel and the command with its Copy button. New fields `claim`, `commandIntro` and `replayLabel` show only for this variant. The turns replay one at a time the first time the plate scrolls into view, with a "Play it again" button whose label is a field. With reduced motion, or without JavaScript, every turn is visible and the button is not shown. - `ruled-split` `rows`: optional paired rows (a label and a value) under the description. With no rows nothing changes. The package gains one client module, `TranscriptStage.client`, holding the replay and the Copy button; the variants themselves stay server components.

  • d08e2ee: The section header gains two variants, Product Split and Transcript Stage, and an optional list of paired rows on Ruled Split. Additive: a stored section header of any existing variant renders the same markup, and a Ruled Split with no rows is byte-identical to before. - `product-split`: copy at the left (title with `*emphasis*`, a rule, a lede, body text, up to two links and an optional list) beside a captioned picture at the right (5/7). Set `imageBleed` to run the picture to the right edge of the screen, cropped flush at the bottom of the band, with the caption above it; leave it off for an inset picture with the caption underneath. Set `titleLevel` to `h1` for the page's opening hero, which also loads the picture first. It is a server component with no entrance animation. - `transcript-stage`: reads the same fields as `transcript-split` (which is unchanged), staged as the headline alone, a large terminal plate beside the lede and copy, then a claim panel and the command with its Copy button. New fields `claim`, `commandIntro` and `replayLabel` show only for this variant. The turns replay one at a time the first time the plate scrolls into view, with a "Play it again" button whose label is a field. With reduced motion, or without JavaScript, every turn is visible and the button is not shown. - `ruled-split` `rows`: optional paired rows (a label and a value) under the description. With no rows nothing changes. The package gains one client module, `TranscriptStage.client`, holding the replay and the Copy button; the variants themselves stay server components.
v0.48.0minor

3954ae9: A page can now carry a breadcrumb trail, an urgent or important notice, and prose set beside its review line and a callout, all from blocks an editor composes. The new `breadcrumb` block is a labeled navigation landmark holding an ordered list of links that ends in the current page (`aria-current="page"`); labels wrap at any width, every link is at least 44px tall, an unsafe link shows as text, and no structured data is emitted. The `post-notice` block gains an optional `tone` (standard, important, urgent) and a `headingLevel`: a toned notice gets a mark before its heading and a heavier edge, never color alone, it stays a static note (no alert role), and an untoned notice renders byte for byte as before. The practice pack's reviewed line is now nestable, so it can sit in a layout grid; build the reading layout with a layout grid in its feature-and-sidebar arrangement holding a text block, the reviewed line and a notice. The block catalog lists the new block.

  • 3954ae9: A page can now carry a breadcrumb trail, an urgent or important notice, and prose set beside its review line and a callout, all from blocks an editor composes. The new `breadcrumb` block is a labeled navigation landmark holding an ordered list of links that ends in the current page (`aria-current="page"`); labels wrap at any width, every link is at least 44px tall, an unsafe link shows as text, and no structured data is emitted. The `post-notice` block gains an optional `tone` (standard, important, urgent) and a `headingLevel`: a toned notice gets a mark before its heading and a heavier edge, never color alone, it stays a static note (no alert role), and an untoned notice renders byte for byte as before. The practice pack's reviewed line is now nestable, so it can sit in a layout grid; build the reading layout with a layout grid in its feature-and-sidebar arrangement holding a text block, the reviewed line and a notice. The block catalog lists the new block.
  • 31e0105: A layout grid now stacks to one column on tablets and phones instead of keeping its desktop columns. The collapse was written as a container query on the grid's own container, which never matches itself, so none of it applied. It is now a viewport query like the Grid block's, and it resets each cell's stored span so the cells stack instead of growing extra columns. When the cells stack, a block inside a cell now fills the cell instead of keeping the offset it would have had on the page grid.
v0.47.2patch

908aacb: The `extras` bundle and the package are now tier `free`, matching the store: six paid packs depend on this package, so any registry token could already install it. The README's counts now include `outcome-hero`.

  • 908aacb: The `extras` bundle and the package are now tier `free`, matching the store: six paid packs depend on this package, so any registry token could already install it. The README's counts now include `outcome-hero`.
v0.46.0minor

39e0892: New outcome-hero block: a statement hero over a before-and-after demo card. New `outcome-hero` block (paid `extras` bundle): a centered statement hero over a demo card. The `toggle` demo shows a before and an after panel (both visible without script, an `aria-pressed` toggle after mount, per-panel callouts, a labeled disclosure footnote); `video` needs captions or a transcript link, `image` needs alt text. One client island, tokens only, a 180ms fade only under `prefers-reduced-motion: no-preference`.

  • 39e0892: New outcome-hero block: a statement hero over a before-and-after demo card. New `outcome-hero` block (paid `extras` bundle): a centered statement hero over a demo card. The `toggle` demo shows a before and an after panel (both visible without script, an `aria-pressed` toggle after mount, per-panel callouts, a labeled disclosure footnote); `video` needs captions or a transcript link, `image` needs alt text. One client island, tokens only, a 180ms fade only under `prefers-reduced-motion: no-preference`.
v0.44.0minor

822d3f9: `section-header` gains an optional `tag` text field (localized), for a short label such as "Demo programs". It renders as `<span data-opener-tag>` right after the kicker or eyebrow line and before the title, in all four variants (`ruled-split` and `transcript-split` inside `[data-opener-group]`, `statement-hero` in the aside, `default` after the eyebrow). Themes style it through the opener hook family (`data-opener`, `data-opener-meta`, `data-opener-title`, `data-opener-link`, `data-opener-group`, `data-opener-body`, and now `data-opener-tag`); the pack ships only a small neutral style. When `tag` is empty or unset nothing is rendered, so existing documents produce byte-identical markup (the golden is unchanged).

  • 822d3f9: `section-header` gains an optional `tag` text field (localized), for a short label such as "Demo programs". It renders as `<span data-opener-tag>` right after the kicker or eyebrow line and before the title, in all four variants (`ruled-split` and `transcript-split` inside `[data-opener-group]`, `statement-hero` in the aside, `default` after the eyebrow). Themes style it through the opener hook family (`data-opener`, `data-opener-meta`, `data-opener-title`, `data-opener-link`, `data-opener-group`, `data-opener-body`, and now `data-opener-tag`); the pack ships only a small neutral style. When `tag` is empty or unset nothing is rendered, so existing documents produce byte-identical markup (the golden is unchanged).
  • d0f11bc: `MediaProps` gains an optional `fetchPriority` (`high`, `low` or `auto`) for the LCP image. The built-in default media adapter renders it as `fetchpriority` on the `<img>` when set, and adds nothing when it is not, so every existing image renders byte for byte as before. An adapter built on `next/image` should pass it through explicitly: next/image 16 does not infer `fetchPriority` from `priority`. The `high-impact-hero` `venue` variant is the first caller.
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.42.3patch

89f941b: The pause control on moving bands (logo marquee, image marquee, content-with-marquee) is now always visible: a 32px round outlined chip with a pause or play glyph in the band's top-right corner, centered in a real 44px button, with a tooltip on hover and focus and a focus ring. It was clipped to a 1px box (`clip-path: inset(50%)`), which removed it from hit testing, then briefly transparent until hover; neither let a visitor find it. The accessible name toggles between "Pause" and "Play" (default labels shortened from "Pause animation" / "Play animation"; `pauseLabel` / `playLabel` still override) and the button no longer sets `aria-pressed`. Colors come from the band's own text color (translucent tints) and the focus-ring token, so it reads on a dark band inside a light page. Reduced motion still hides it.

  • 89f941b: The pause control on moving bands (logo marquee, image marquee, content-with-marquee) is now always visible: a 32px round outlined chip with a pause or play glyph in the band's top-right corner, centered in a real 44px button, with a tooltip on hover and focus and a focus ring. It was clipped to a 1px box (`clip-path: inset(50%)`), which removed it from hit testing, then briefly transparent until hover; neither let a visitor find it. The accessible name toggles between "Pause" and "Play" (default labels shortened from "Pause animation" / "Play animation"; `pauseLabel` / `playLabel` still override) and the button no longer sets `aria-pressed`. Colors come from the band's own text color (translucent tints) and the focus-ring token, so it reads on a dark band inside a light page. Reduced motion still hides it.
v0.42.2patch

da53d7f: `section-header` has a new `transcript-split` variant: a copy column with a lede, a copyable command row and a button, beside a sticky plate holding a labeled sample exchange. New optional fields, all shown only for this variant: `lede`, `descriptionAfter`, `command` (`code`, `language`, `label`, `copyable`), `transcript` (rows of `role`, `text`, `isCode`), `transcriptHeading`, `transcriptLabel` (required when the transcript has rows) and `transcriptCaption`. The button is the block's existing `link`. The Copy button is the variant's only client code; the other variants and stored documents render as before.

  • da53d7f: `section-header` has a new `transcript-split` variant: a copy column with a lede, a copyable command row and a button, beside a sticky plate holding a labeled sample exchange. New optional fields, all shown only for this variant: `lede`, `descriptionAfter`, `command` (`code`, `language`, `label`, `copyable`), `transcript` (rows of `role`, `text`, `isCode`), `transcriptHeading`, `transcriptLabel` (required when the transcript has rows) and `transcriptCaption`. The button is the block's existing `link`. The Copy button is the variant's only client code; the other variants and stored documents render as before.
v0.38.0minor

cff5b22: `section-header` `ruled-split` emits the shared opener hooks: `data-opener` on the root, `data-opener-meta` on the kicker (`category`), `data-opener-title` on the title and `data-opener-link` on a new "more" link. The link is opt-in: a new checkbox `showOpenerLink` (ruled-split only, no default) shows the block's existing `link` group as a "more" link after the title, for a custom URL with a label; an internal reference renders nothing (this variant has no link resolver). A stored document, even one with `enableLink` on, renders exactly as before apart from the new `data-opener*` attributes, as do the `default` and `statement-hero` variants. `ruled-split` also marks its inner wrapper `data-opener-group` and its description `data-opener-body` (the description wrapper renders only when there is a description), so a theme reaches them by hook. Additive: the other variants and the stored markup are unchanged apart from these attributes.

  • cff5b22: `section-header` `ruled-split` emits the shared opener hooks: `data-opener` on the root, `data-opener-meta` on the kicker (`category`), `data-opener-title` on the title and `data-opener-link` on a new "more" link. The link is opt-in: a new checkbox `showOpenerLink` (ruled-split only, no default) shows the block's existing `link` group as a "more" link after the title, for a custom URL with a label; an internal reference renders nothing (this variant has no link resolver). A stored document, even one with `enableLink` on, renders exactly as before apart from the new `data-opener*` attributes, as do the `default` and `statement-hero` variants. `ruled-split` also marks its inner wrapper `data-opener-group` and its description `data-opener-body` (the description wrapper renders only when there is a description), so a theme reaches them by hook. Additive: the other variants and the stored markup are unchanged apart from these attributes.
v0.37.1patch

f9fe8fe: Feature Hero shows icons instead of their names, and four blocks stay inside a phone-width window. - **Feature Hero:** a feature's `icon` is a lucide name ("shield-check") or an emoji, but the renderer printed the name as text in the card. It now uses extras' shared `resolveLucideIcon` (`./render/shared`, which extras has exported since 0.28.0), the same resolver extras and catalog-pack blocks use. An unknown name shows no icon, and emoji stay text. The extras map gains `shield-check` and `workflow`. - **Feature Hero grids:** the authored two- and three-column feature grids kept their columns on a phone and ran 380px past the window. Tracks are now `minmax(0, 1fr)`, and the grids stack below 640px. In a card with its image beside the text, the text drops below the image when it would get less than 10rem, instead of breaking words mid-way or pushing the card wider. - **Status Board:** the same fix to its card grid. It was 24px past its box at 375px and now stacks below 640px. - **Editorial Colophon:** its `3 / 11` placement counts the page grid's 16 desktop tracks, so below 1024px it overran into the right gutter. It sits on the content lines there and keeps its desktop placement. - **FAQ:** Full Width is padded by the page gutter, like Feature Hero's and CTA's Full Width.

  • f9fe8fe: Feature Hero shows icons instead of their names, and four blocks stay inside a phone-width window. - **Feature Hero:** a feature's `icon` is a lucide name ("shield-check") or an emoji, but the renderer printed the name as text in the card. It now uses extras' shared `resolveLucideIcon` (`./render/shared`, which extras has exported since 0.28.0), the same resolver extras and catalog-pack blocks use. An unknown name shows no icon, and emoji stay text. The extras map gains `shield-check` and `workflow`. - **Feature Hero grids:** the authored two- and three-column feature grids kept their columns on a phone and ran 380px past the window. Tracks are now `minmax(0, 1fr)`, and the grids stack below 640px. In a card with its image beside the text, the text drops below the image when it would get less than 10rem, instead of breaking words mid-way or pushing the card wider. - **Status Board:** the same fix to its card grid. It was 24px past its box at 375px and now stacks below 640px. - **Editorial Colophon:** its `3 / 11` placement counts the page grid's 16 desktop tracks, so below 1024px it overran into the right gutter. It sits on the content lines there and keeps its desktop placement. - **FAQ:** Full Width is padded by the page gutter, like Feature Hero's and CTA's Full Width.
v0.37.0minor

3d47fcd: `article-hero` gains a `margin` variant: an essay header laid out on the page grid, with an optional running head that tracks reading progress. The left lane (the `marginalia-left` grid lines from 1024px, stacked above the column below that) carries `serial` ("No. 14"), `kind` ("Essay"), the byline, `publishedDate` and the new `revisedDate`, and `summary` (`label`, default "In short", and `items[]`). The reading column carries the headline, the standfirst (`description`) and `topics[]` as chips. All of the new fields are optional and show in the editor only for `margin`; the `post` and `chapter` variants render exactly as before. The new `runningHead` group (`enabled`, `title`, `author`, `minutes`) adds a sticky head: author left, title and a reading-time label right, over a hairline rule with an accent fill. The server renders "N min read" and no fill. After mount, a small client island finds the first `[data-reading-body]` on the page, writes the progress (0 to 1) to `--tome-reading-progress` on the fill, and the label goes "N min read", "N min left", "Finished". Under reduced motion, without JS, or with no reading body on the page, the label stays "N min read" and the fill is hidden. `minutes` defaults to `readTime`. Hooks for themes: `data-block-variant="margin"`, `data-hero-side`, `data-hero-serial`, `data-hero-kind`, `data-hero-byline`, `data-hero-dates`, `data-hero-summary`, `data-hero-topics`, `data-hero-topic`, `data-hero-standfirst`, and on the running head `data-running-head`, `data-running-head-author`, `data-running-head-title`, `data-running-head-time`, `data-running-head-rule`, `data-running-head-fill` and `data-running-head-state` (`static` or `live`). `ArticleHeroMargin`, `RunningHead` and the pure helpers `readingProgress` and `readingLabel` are exported from `./render`, so a site's post template can render the header from its own post data.

  • 3d47fcd: `article-hero` gains a `margin` variant: an essay header laid out on the page grid, with an optional running head that tracks reading progress. The left lane (the `marginalia-left` grid lines from 1024px, stacked above the column below that) carries `serial` ("No. 14"), `kind` ("Essay"), the byline, `publishedDate` and the new `revisedDate`, and `summary` (`label`, default "In short", and `items[]`). The reading column carries the headline, the standfirst (`description`) and `topics[]` as chips. All of the new fields are optional and show in the editor only for `margin`; the `post` and `chapter` variants render exactly as before. The new `runningHead` group (`enabled`, `title`, `author`, `minutes`) adds a sticky head: author left, title and a reading-time label right, over a hairline rule with an accent fill. The server renders "N min read" and no fill. After mount, a small client island finds the first `[data-reading-body]` on the page, writes the progress (0 to 1) to `--tome-reading-progress` on the fill, and the label goes "N min read", "N min left", "Finished". Under reduced motion, without JS, or with no reading body on the page, the label stays "N min read" and the fill is hidden. `minutes` defaults to `readTime`. Hooks for themes: `data-block-variant="margin"`, `data-hero-side`, `data-hero-serial`, `data-hero-kind`, `data-hero-byline`, `data-hero-dates`, `data-hero-summary`, `data-hero-topics`, `data-hero-topic`, `data-hero-standfirst`, and on the running head `data-running-head`, `data-running-head-author`, `data-running-head-title`, `data-running-head-time`, `data-running-head-rule`, `data-running-head-fill` and `data-running-head-state` (`static` or `live`). `ArticleHeroMargin`, `RunningHead` and the pure helpers `readingProgress` and `readingLabel` are exported from `./render`, so a site's post template can render the header from its own post data.
v0.36.3patch

1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.

  • 1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.
v0.36.2patch

3a84675: PageHero's primary button label and PostHero's text over a photo are readable again in every theme. PageHero: a host's global link reset (`:root a { color: inherit }`) outranked the single `.link` class, so the pill took the hero's text color. Over an image that is the pill's own background, and the label vanished. The color rules now use doubled classes, the convention HeaderCTAButtons, Ledger and CampaignTiers already follow. PostHero (`image-overlay`): the scrim is built from foreground, but the text stayed on-primary, so in a dark theme it was light text on a light wash. Over an image the text now takes background, the scrim's partner, the same pairing PageHero, BlogHero and ChapterHero use.

  • 3a84675: PageHero's primary button label and PostHero's text over a photo are readable again in every theme. PageHero: a host's global link reset (`:root a { color: inherit }`) outranked the single `.link` class, so the pill took the hero's text color. Over an image that is the pill's own background, and the label vanished. The color rules now use doubled classes, the convention HeaderCTAButtons, Ledger and CampaignTiers already follow. PostHero (`image-overlay`): the scrim is built from foreground, but the text stayed on-primary, so in a dark theme it was light text on a light wash. Over an image the text now takes background, the scrim's partner, the same pairing PageHero, BlogHero and ChapterHero use.
v0.36.0patch

b1c933c: Four layout blocks keep their page-grid lines at every width, and the corner-notch background no longer requests `/blocks/undefined`. `bento-section`, `content-with-bento`, `content-with-corner-notch` and `grid` are the four blocks. Their roots declared a size container on a `subgrid`, which Chromium and WebKit answer by switching subgrid off. Their responsive rules are now viewport queries (the blocks are full-bleed), with the breakpoint itself on the wide side. The corner-notch background paints only when its upload resolved to a URL, and is set through `--notch-bg-image` and `--notch-bg-opacity`.

  • b1c933c: Four layout blocks keep their page-grid lines at every width, and the corner-notch background no longer requests `/blocks/undefined`. `bento-section`, `content-with-bento`, `content-with-corner-notch` and `grid` are the four blocks. Their roots declared a size container on a `subgrid`, which Chromium and WebKit answer by switching subgrid off. Their responsive rules are now viewport queries (the blocks are full-bleed), with the breakpoint itself on the wide side. The corner-notch background paints only when its upload resolved to a URL, and is set through `--notch-bg-image` and `--notch-bg-opacity`.
v0.35.0patch

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

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

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

  • e8213a7: Wide layouts start at 768px again, and the FAQ and the team-roster `people` variant keep their inner lines on the page grid. - **768px is the wide layout.** The narrow-screen viewport queries added in the previous release were inclusive (`max-width: 768px`), so at exactly 768px these blocks stacked although the platform's other queries and the designs start the wide layout there. They now end just below the breakpoint (`max-width: 767.98px`; 639.98px for `SectionHeader`): `high-impact-hero` (marketing-starter); `BlogHero`, `ChapterHero`, `CustomHero`, `ImageHero`, `LowImpactHero`, `MediumImpactHero`, `PageHero`, `StudyHero`, `TypographyHero` and `SectionHeader` (extras); `PostHero` (content-writer); `SplitView` (agency-essentials). Each pack's test fails a converted query that includes its breakpoint. - **Subgrid column gaps.** The `faq` root and the team-roster `people` section are subgrids of the page grid, but each also set a column gap (the FAQ through its `gap` shorthand, the people section through the shared roster `gap`). The page grid has no column gap, so every line inside them moved by half the gap: a theme's FAQ list on `reading-start` started 9px inside the reading column, and the people header and grid 9px inside the content lane. Both now set a row gap only (`column-gap: normal`, the parent's gap on a subgrid).
  • c00339d: Hero link rows (BlogHero, ChapterHero, TypographyHero, through the shared `HeroLinkList`) now carry each link's appearance as `data-appearance` (`default` | `outline`), the hook chrome and block CTAs already carry, so a theme can tell an outline action from the primary one. A link with no appearance renders no attribute; classes are unchanged. Theme note: links that set an appearance now match the themes' `:is(a, button)[data-appearance]:not([data-appearance="inline"])` pill rules (Groundwork: full radius; Counsel: full radius, sans, medium, tracking; Table: the 50px pill, the ink fill for `default`, the ring for `outline`), and drop out of Table's `main a:not([data-appearance]):hover` underline.
v0.34.0patch

3e4f0a7: Narrow-screen rules that never fired now fire. These blocks wrote their phone layout as an unnamed `@container` query, but nothing around them declares a size container (not tome-ui, the blocks core or the starter), so the rules never matched: the `high-impact-hero` `split` and default layouts never stacked on phones. Declaring the container on the block root would not work either, because size containment turns a `subgrid` root into a plain grid and drops the page-grid lines its children sit on. The rules are viewport queries now, at the same breakpoints; the blocks are full-bleed, so the viewport is their width. - `@wabbit/tome-blocks-marketing-starter`: `high-impact-hero` (two columns stack to one, large headline sizes step down, at 768px and below). - `@wabbit/tome-blocks-extras`: `BlogHero`, `ChapterHero`, `CustomHero`, `ImageHero`, `LowImpactHero`, `MediumImpactHero`, `PageHero`, `StudyHero`, `TypographyHero` (768px) and `SectionHeader` (640px). - `@wabbit/tome-blocks-content-writer`: `PostHero` (768px). - `@wabbit/tome-blocks-agency-essentials`: `SplitView` stacks at 768px. Above those widths nothing changes.

  • 3e4f0a7: Narrow-screen rules that never fired now fire. These blocks wrote their phone layout as an unnamed `@container` query, but nothing around them declares a size container (not tome-ui, the blocks core or the starter), so the rules never matched: the `high-impact-hero` `split` and default layouts never stacked on phones. Declaring the container on the block root would not work either, because size containment turns a `subgrid` root into a plain grid and drops the page-grid lines its children sit on. The rules are viewport queries now, at the same breakpoints; the blocks are full-bleed, so the viewport is their width. - `@wabbit/tome-blocks-marketing-starter`: `high-impact-hero` (two columns stack to one, large headline sizes step down, at 768px and below). - `@wabbit/tome-blocks-extras`: `BlogHero`, `ChapterHero`, `CustomHero`, `ImageHero`, `LowImpactHero`, `MediumImpactHero`, `PageHero`, `StudyHero`, `TypographyHero` (768px) and `SectionHeader` (640px). - `@wabbit/tome-blocks-content-writer`: `PostHero` (768px). - `@wabbit/tome-blocks-agency-essentials`: `SplitView` stacks at 768px. Above those widths nothing changes.
v0.32.0minor

072f973: New optional fields and theme hooks: an inline call link on the split hero, image labels, a quotes-grid testimonial layout, an open-first FAQ, and a stat-strip note. **marketing-starter, `high-impact-hero`:** the `split` variant now shows `callAction` (`label`, `phone`) and renders it as an inline `tel:` link after the buttons, for example "or call (555) 010-2030" (`data-hero-call`). An empty label reads "or call" after buttons and "Call" without them. In every variant that shows supporting images, `supportingImages[]` gains `label`, a short tag set over the image such as "Illustrative photo" (`data-compliance-label`), and `captionMeta`, a smaller second caption line (`data-caption-meta`). Every variant that renders actions marks its action row `data-hero-actions` (quick-ask already did): the row holding the buttons and the inline call on `split`, otherwise the button row itself. The attribute is the only markup change. **marketing-starter, `testimonial`:** each entry gains an optional `complianceLabel`, shown with its quote in every layout (`data-compliance-label`). The `layout` select adds `quotes-grid`: every testimonial becomes a short quote in a responsive grid, with an optional `stat` tile (`value`, `text`, `label`, `position`) placed at its 1-based cell among the quotes, default 2. Hooks: `data-testimonial-layout="quotes-grid"`, `data-testimonial-cell` on every cell, `data-testimonial-stat` on the tile, `data-testimonial-stat-value` on its value. **marketing-starter, `faq`:** an `openFirst` checkbox (default off) renders the first question open and marks the root with `data-faq-open-first`. Every FAQ now carries part hooks: `data-faq-item` on each `<details>`, `data-faq-question` on its `<summary>`, and `data-faq-answer` on a new plain `<div>` around each answer (the answer's own element comes from your rich-text adapter, which accepts only a class name). **proposal-pack, `proposal-stat-strip`:** an optional `note` renders a source or compliance line under the figures (`data-stat-note`), with the same tokens as the stats. When the section header does not show, the root now carries `data-stat-strip-headless`; nothing renders in the header's place, so no gap is left above the figures. Every new field is optional, and the rendered markup is unchanged while they are empty; the additions to existing output are the FAQ part-hook attributes, the unstyled answer wrapper, the `data-hero-actions` attribute on hero action rows, and the `data-stat-strip-headless` attribute on stat strips that already had no header. The new fields add columns, so run your Payload migration and regenerate types. **extras:** the shared `HeroLinkList` accepts an optional `containerData` (extra `data-*` attributes for its row element). Omitted, the row renders exactly as before.

  • 072f973: New optional fields and theme hooks: an inline call link on the split hero, image labels, a quotes-grid testimonial layout, an open-first FAQ, and a stat-strip note. **marketing-starter, `high-impact-hero`:** the `split` variant now shows `callAction` (`label`, `phone`) and renders it as an inline `tel:` link after the buttons, for example "or call (555) 010-2030" (`data-hero-call`). An empty label reads "or call" after buttons and "Call" without them. In every variant that shows supporting images, `supportingImages[]` gains `label`, a short tag set over the image such as "Illustrative photo" (`data-compliance-label`), and `captionMeta`, a smaller second caption line (`data-caption-meta`). Every variant that renders actions marks its action row `data-hero-actions` (quick-ask already did): the row holding the buttons and the inline call on `split`, otherwise the button row itself. The attribute is the only markup change. **marketing-starter, `testimonial`:** each entry gains an optional `complianceLabel`, shown with its quote in every layout (`data-compliance-label`). The `layout` select adds `quotes-grid`: every testimonial becomes a short quote in a responsive grid, with an optional `stat` tile (`value`, `text`, `label`, `position`) placed at its 1-based cell among the quotes, default 2. Hooks: `data-testimonial-layout="quotes-grid"`, `data-testimonial-cell` on every cell, `data-testimonial-stat` on the tile, `data-testimonial-stat-value` on its value. **marketing-starter, `faq`:** an `openFirst` checkbox (default off) renders the first question open and marks the root with `data-faq-open-first`. Every FAQ now carries part hooks: `data-faq-item` on each `<details>`, `data-faq-question` on its `<summary>`, and `data-faq-answer` on a new plain `<div>` around each answer (the answer's own element comes from your rich-text adapter, which accepts only a class name). **proposal-pack, `proposal-stat-strip`:** an optional `note` renders a source or compliance line under the figures (`data-stat-note`), with the same tokens as the stats. When the section header does not show, the root now carries `data-stat-strip-headless`; nothing renders in the header's place, so no gap is left above the figures. Every new field is optional, and the rendered markup is unchanged while they are empty; the additions to existing output are the FAQ part-hook attributes, the unstyled answer wrapper, the `data-hero-actions` attribute on hero action rows, and the `data-stat-strip-headless` attribute on stat strips that already had no header. The new fields add columns, so run your Payload migration and regenerate types. **extras:** the shared `HeroLinkList` accepts an optional `containerData` (extra `data-*` attributes for its row element). Omitted, the row renders exactly as before.
v0.31.0patch

fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.

  • fc84e67: Self-animating motion components now mark their own root with `data-tome-motion="self"`, so the opt-in platform scroll reveal in `@wabbit/tome-ui` never animates their blocks a second time. - `@wabbit/tome-blocks-core`: the `Reveal` boundary carries the marker, which covers every block that wraps its root in `Reveal`. - `@wabbit/tome-blocks-dossier-pack`: the entrance and motion islands (`StageMotion`, `DossierReveal`, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it. `RevealScope` renders no element of its own; when the pack reveal is on, it renders `DossierReveal`, which carries the marker. - `@wabbit/tome-blocks-campaign-pack`: `CampaignReveal` carries it. As in the dossier pack, `RevealScope` adds no element. - `@wabbit/tome-blocks-cinema-pack`: the pull interlude, scene caption and statement band motion and the scrub story stage carry it. - `@wabbit/tome-blocks-extras`: the animated `Showcase` root carries it. The static fallback does not, because it does not animate. The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.
  • b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.28.0minor

868c087: Marquees can now be paused: they stop on hover, on keyboard focus and on a touch tap, and carry a pause button that appears when it takes keyboard focus. The `marquee`, `image-marquee` and `content-with-marquee` blocks (and so `content-pro`'s marquee variant) hold still while the pointer is over the band, while focus is on something inside it, and after a touch tap on the band (a tap on a link still follows the link). Each also has a real pause button, first in the band, that is hidden until a keyboard user tabs to it and then shows with a focus ring and a 44px target without moving the layout. Under `prefers-reduced-motion: reduce` the band does not move and the button is hidden. The marquee's repeated copy is now hidden from screen readers. New exports from `@wabbit/tome-blocks-extras/render/shared`, for a pack that has its own moving band: `useMotionPause`, `MotionPauseButton` and `MotionPauseControl`, with their prop types. `MotionPauseControl` drops into any CSS-animated band as its first child and sets `data-paused="true"` on the band while paused. The button names come from `pauseLabel` / `playLabel` props ("Pause animation" / "Play animation" by default) so a site can translate them. `ImageMarquee` also accepts an optional `pauseScope` ref for a wrapper that holds links over the band.

  • 868c087: Marquees can now be paused: they stop on hover, on keyboard focus and on a touch tap, and carry a pause button that appears when it takes keyboard focus. The `marquee`, `image-marquee` and `content-with-marquee` blocks (and so `content-pro`'s marquee variant) hold still while the pointer is over the band, while focus is on something inside it, and after a touch tap on the band (a tap on a link still follows the link). Each also has a real pause button, first in the band, that is hidden until a keyboard user tabs to it and then shows with a focus ring and a 44px target without moving the layout. Under `prefers-reduced-motion: reduce` the band does not move and the button is hidden. The marquee's repeated copy is now hidden from screen readers. New exports from `@wabbit/tome-blocks-extras/render/shared`, for a pack that has its own moving band: `useMotionPause`, `MotionPauseButton` and `MotionPauseControl`, with their prop types. `MotionPauseControl` drops into any CSS-animated band as its first child and sets `data-paused="true"` on the band while paused. The button names come from `pauseLabel` / `playLabel` props ("Pause animation" / "Play animation" by default) so a site can translate them. `ImageMarquee` also accepts an optional `pauseScope` ref for a wrapper that holds links over the band.
  • 483e0a1: Preview data in the extras pack now uses invented brand and people names throughout. The custom hero preview uses the Public classification, and the contributor and product names in several previews are replaced with fictional ones. Block fields and variants are unchanged.
  • 6301bf1: The custom hero's design options now have plain descriptive names, and its stored values are unchanged, so existing pages render the same.
  • 58655f4: Blocks now carry the neutral Tome source tag instead of the legacy source tag, so the gallery lists them as Tome blocks; stored content is unchanged.
v0.27.0minor

c14a133: `lexical-document-embed` now relates to `pages` by default, so registering every extras block no longer needs a `documents` collection. Payload refuses to start when a relationship points at a collection the config doesn't have, and few sites have `documents`. The default is now `pages`, like `lexical-entity-reference`. Pass `documentCollection` to point it anywhere else. **BREAKING:** a site that relied on the old `documents` default must now pass `documentCollection: 'documents'` to keep the same schema.

  • c14a133: `lexical-document-embed` now relates to `pages` by default, so registering every extras block no longer needs a `documents` collection. Payload refuses to start when a relationship points at a collection the config doesn't have, and few sites have `documents`. The default is now `pages`, like `lexical-entity-reference`. Pass `documentCollection` to point it anywhere else. **BREAKING:** a site that relied on the old `documents` default must now pass `documentCollection: 'documents'` to keep the same schema.
  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.26.0patch

f52288f: Per-word text no longer runs together, and `lexical-motion-text` is visible by default. `text-reveal` (editorial pack) and `lexical-motion-text` (extras) put the space after each word inside an `inline-block` span, where a trailing space collapses, so every word rendered glued to the next. The space is now a real text node between the word spans, so words separate, lines wrap between words, and copied text and screen readers get the sentence with spaces. `text-reveal` also drops empty words from leading or trailing whitespace. The block's meta, variant and field descriptions no longer claim a scroll-driven reveal that is not wired; the text was always fully visible. `lexical-motion-text` words rest visible and in place; the reveal now runs from a hidden first frame as an enhancement instead of ending there, so text shows without the animation. A `prefers-reduced-motion: reduce` rule shows the words with no animation. The effect is unchanged for visitors with motion enabled. No exports, fields or signatures change.

  • f52288f: Per-word text no longer runs together, and `lexical-motion-text` is visible by default. `text-reveal` (editorial pack) and `lexical-motion-text` (extras) put the space after each word inside an `inline-block` span, where a trailing space collapses, so every word rendered glued to the next. The space is now a real text node between the word spans, so words separate, lines wrap between words, and copied text and screen readers get the sentence with spaces. `text-reveal` also drops empty words from leading or trailing whitespace. The block's meta, variant and field descriptions no longer claim a scroll-driven reveal that is not wired; the text was always fully visible. `lexical-motion-text` words rest visible and in place; the reveal now runs from a hidden first frame as an enhancement instead of ending there, so text shows without the animation. A `prefers-reduced-motion: reduce` rule shows the words with no animation. The effect is unchanged for visitors with motion enabled. No exports, fields or signatures change.
  • 8c84e70: Hero text stays paired with the surface behind it. page-hero with a background image takes the scrim's partner ink, so its copy no longer goes light on a pale scrim in dark themes. blog-hero, chapter-hero (and article-hero's chapter variant) and typography-hero with an author-picked background color map the Light/Dark text choice to fixed inks (`on-solid-dark` / `on-inverse`) instead of the theme-flipping page pair, which made the chapter headline invisible on its light card in dark themes.
v0.22.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Every extras block now ships its CSS only on pages that render it, instead of in every page's CSS bundle. The 50 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - Marquee, ImageMarquee and Showcase are client components. Each is now a server-safe wrapper (`X.tsx`, which renders the stylesheet and registers the renderer) around `X.client.tsx`. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode an extras class at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Every extras block now ships its CSS only on pages that render it, instead of in every page's CSS bundle. The 50 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - Marquee, ImageMarquee and Showcase are client components. Each is now a server-safe wrapper (`X.tsx`, which renders the stylesheet and registers the renderer) around `X.client.tsx`. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode an extras class at equal specificity, and won only by loading later, no longer wins.
v0.20.1patch

aaf3533: Adds spacing between consecutive rich-text paragraphs and fixes a layout/interaction gap in two editorial variants. `@wabbit/tome-blocks-extras`: the `section-header` block's `statement-hero` and `ruled-split` variants now add space between consecutive paragraphs in their rich-text body copy, instead of every paragraph running edge to edge with no breathing room. `@wabbit/tome-blocks-editorial-pack`: the `feature` block's `walk-strip` and `ledger-dark` variants get the same consecutive-paragraph spacing in their rich-text body copy. `walk-strip`'s stop cards are now a single click target end to end (previously only the route link text itself was clickable), with a visible focus ring on the whole card when the link is keyboard-focused. `ledger-dark`'s USP grid now runs 2-up by default, 3-up at desktop widths, and 4-up at desktop widths specifically when there are exactly four entries — previously it was a fixed 3-up grid that collapsed straight to one column below tablet width. `walk-strip` also lays out a five-stop walk cleanly: five across at 1200px and wider, and 3 + 2 between 861 and 1199px, staggered on the middle column so no two cards touch. A five-stop walk previously wrapped its fifth stop alone onto a second row.

  • aaf3533: Adds spacing between consecutive rich-text paragraphs and fixes a layout/interaction gap in two editorial variants. `@wabbit/tome-blocks-extras`: the `section-header` block's `statement-hero` and `ruled-split` variants now add space between consecutive paragraphs in their rich-text body copy, instead of every paragraph running edge to edge with no breathing room. `@wabbit/tome-blocks-editorial-pack`: the `feature` block's `walk-strip` and `ledger-dark` variants get the same consecutive-paragraph spacing in their rich-text body copy. `walk-strip`'s stop cards are now a single click target end to end (previously only the route link text itself was clickable), with a visible focus ring on the whole card when the link is keyboard-focused. `ledger-dark`'s USP grid now runs 2-up by default, 3-up at desktop widths, and 4-up at desktop widths specifically when there are exactly four entries — previously it was a fixed 3-up grid that collapsed straight to one column below tablet width. `walk-strip` also lays out a five-stop walk cleanly: five across at 1200px and wider, and 3 + 2 between 861 and 1199px, staggered on the middle column so no two cards touch. A five-stop walk previously wrapped its fifth stop alone onto a second row.
v0.20.0patch

9ac8d3d: Section header's regular-weight variant now reads the declared `--tome-type-weight-regular` (it read the undeclared `--tome-type-weight-normal`, so the weight fell back to inherit). Showcase's `--tome-radius-xl` corners, the feature-steps square badge's `--tome-radius-none` and the bento/media-feature `--tome-motion-normal` transitions now resolve, because `@wabbit/tome-ui` declares those tokens.

  • 9ac8d3d: Section header's regular-weight variant now reads the declared `--tome-type-weight-regular` (it read the undeclared `--tome-type-weight-normal`, so the weight fell back to inherit). Showcase's `--tome-radius-xl` corners, the feature-steps square badge's `--tome-radius-none` and the bento/media-feature `--tome-motion-normal` transitions now resolve, because `@wabbit/tome-ui` declares those tokens.
v0.18.3patch

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

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

c8827e7: Internal refactor: collection slugs are now typed through one package-internal helper instead of inline casts scattered across the source. No API or behavior change.

  • c8827e7: Internal refactor: collection slugs are now typed through one package-internal helper instead of inline casts scattered across the source. No API or behavior change.
  • dfcd370: Fixes gallery variants for `section-header`, catalog-slug renderer lookup for four blocks, and the empty `useRichTextAdapter()` fallback. - `extrasBlockMeta` now lists `section-header`'s three variants (`default`, `statement-hero`, `ruled-split`); the gallery previously showed only the default. - The 14 deprecated blocks that lacked a `@deprecated` JSDoc tag on their meta now carry one, so editors flag them and the README-contract check sees all 16. - `custom`, `card`, `checklist` and `text-block` have a Payload slug that is a different name from their catalog slug (`customblock`, `cardBlock`, `checkList`, `text`). Their renderers are now registered under both, in the `renderers` map and at import time, so a gallery lookup by catalog slug finds them. Stored `blockType` values are unchanged. - `useRichTextAdapter()` with no `<RichTextAdapterProvider>` mounted now returns an adapter that renders through `resolveRichText()`: a registered adapter first, then blocks-core's built-in Lexical renderer. A mounted Provider still wins.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.3patch

9babc42: Omit nested-blocks fields when the consumer configured no allowlist, instead of emitting `blocks: []`. `editorialSpread`, `editorialSection` (`main`), `stackingWrapper` (`cards`) and `layoutGrid` (`items`) take their nested block allowlist from consumer config and defaulted it to `[]`, emitting the field regardless. That is not a harmless empty picker. Payload's client-config conversion guards both keys on length: ```js if (incomingField.blockReferences?.length) { ... } if (incomingField.blocks?.length) { ... } ``` so an empty array produces a client field carrying NEITHER key, and `@payloadcms/ui`'s `buildClientFieldSchemaMap` then evaluates `(field.blockReferences ?? field.blocks).map(...)` on undefined. It throws inside `renderDocument`, so **every** document edit view in the consuming admin renders blank or 500s — not only pages using the block. Observed 2026-09-09 on starter.wabbit.com, which registers these blocks with no config: Pages and Posts rendered an empty admin body while Media and Users were unaffected, with the REST layer healthy throughout. An unconfigured surface now degrades to absent rather than present-and-malformed. Consumers that do pass an allowlist are unchanged. Regression coverage lives in `blocks-editorial-pack/test/nested-blocks-allowlist.test.ts` and `blocks-extras/test/nested-blocks-allowlist.test.ts`; both gates were proven non-vacuous by reverting each guard and confirming the omission assertions fail. `layoutGrid` was found by sweeping the repo for the rest of the defect class rather than by a second field report — no consumer registers it today, so it was latent, not live. Any nested-blocks field whose allowlist is consumer-injected belongs to this class and must omit rather than emit empty. - @wabbit/tome-blocks-core@0.16.0

  • 9babc42: Omit nested-blocks fields when the consumer configured no allowlist, instead of emitting `blocks: []`. `editorialSpread`, `editorialSection` (`main`), `stackingWrapper` (`cards`) and `layoutGrid` (`items`) take their nested block allowlist from consumer config and defaulted it to `[]`, emitting the field regardless. That is not a harmless empty picker. Payload's client-config conversion guards both keys on length: ```js if (incomingField.blockReferences?.length) { ... } if (incomingField.blocks?.length) { ... } ``` so an empty array produces a client field carrying NEITHER key, and `@payloadcms/ui`'s `buildClientFieldSchemaMap` then evaluates `(field.blockReferences ?? field.blocks).map(...)` on undefined. It throws inside `renderDocument`, so **every** document edit view in the consuming admin renders blank or 500s — not only pages using the block. Observed 2026-09-09 on starter.wabbit.com, which registers these blocks with no config: Pages and Posts rendered an empty admin body while Media and Users were unaffected, with the REST layer healthy throughout. An unconfigured surface now degrades to absent rather than present-and-malformed. Consumers that do pass an allowlist are unchanged. Regression coverage lives in `blocks-editorial-pack/test/nested-blocks-allowlist.test.ts` and `blocks-extras/test/nested-blocks-allowlist.test.ts`; both gates were proven non-vacuous by reverting each guard and confirming the omission assertions fail. `layoutGrid` was found by sweeping the repo for the rest of the defect class rather than by a second field report — no consumer registers it today, so it was latent, not live. Any nested-blocks field whose allowlist is consumer-injected belongs to this class and must omit rather than emit empty. - @wabbit/tome-blocks-core@0.16.0
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0
v0.15.24patch

1471078: Post Hero and Custom Hero catalog copy now describes what the blocks render, not their upstream military lore. Post Hero renders a full-bleed cover-image header with an accent-marked category tag, a visibility badge, and a mono byline strip — nothing about it reads "SITREP tactical," so the description, editorial role, and both variant descriptions now say what the reader sees. Custom Hero's description drops the "COP tactical layouts / SITREP post headers" jargon for plain treatment names. blocks-core BLOCK_CATALOG mirror entries updated to match. Enum IDs (`sitrep1`, `cop1`…) and schema field names are unchanged, per the 71d3b09 purge discipline.

  • 1471078: Post Hero and Custom Hero catalog copy now describes what the blocks render, not their upstream military lore. Post Hero renders a full-bleed cover-image header with an accent-marked category tag, a visibility badge, and a mono byline strip — nothing about it reads "SITREP tactical," so the description, editorial role, and both variant descriptions now say what the reader sees. Custom Hero's description drops the "COP tactical layouts / SITREP post headers" jargon for plain treatment names. blocks-core BLOCK_CATALOG mirror entries updated to match. Enum IDs (`sitrep1`, `cop1`…) and schema field names are unchanged, per the 71d3b09 purge discipline.
  • Updated dependencies [1471078] - @wabbit/tome-blocks-core@0.15.24
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
v0.15.9patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-core@0.15.9
v0.15.7patch

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. - @wabbit/tome-blocks-core@0.15.0

  • 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. - @wabbit/tome-blocks-core@0.15.0
v0.15.3patch

485ae7b: Marquee / ImageMarquee: pause the ticker while the block is off-screen. Both renderers drive their transform from GSAP's ticker, which writes an inline style on every frame for as long as the component is mounted — with no regard for whether the element is anywhere near the viewport. Each write costs a style recalculation. Measured on wabbit.com: the ContentWithMarquee band sits ~4400px below the fold on `/architecture-sprint`, and on a freshly-loaded page that nobody had scrolled it was still rewriting its transform ~119 times a second. That drove ~144 style recalcs/s and ~8% of a core, indefinitely, and is what produced user reports of Chrome's "this tab is slowing your browser" prompt. A CPU profile of the same idle page came back 93.8% idle with no JS function above 1% — the cost is entirely style recalculation, not script, which is why it is easy to miss. `useInViewport` (new, IntersectionObserver-backed) now gates `useTicker`'s `enabled`. On-screen behavior is unchanged; a `200px` rootMargin starts the loop just before the band scrolls in so it is never seen starting from a dead stop. The hook defaults to `true` and bails out where IntersectionObserver is unavailable, so the degraded path is "animates" (today's behavior) rather than a silently frozen marquee. Note for anyone touching this: `inViewport` must be passed to `useTicker` as BOTH `enabled` and a member of `dependencies`. `useTicker` reads `enabled` inside a `useGSAP` effect keyed on that array, so omitting it leaves the gate frozen at its mount-time value and the block animates off-screen exactly as before — with no type error and no runtime symptom short of profiling. A source-level audit in `test/marquee-viewport-gate.test.ts` pins the invariant (verified to fail when the dependency is removed).

  • 485ae7b: Marquee / ImageMarquee: pause the ticker while the block is off-screen. Both renderers drive their transform from GSAP's ticker, which writes an inline style on every frame for as long as the component is mounted — with no regard for whether the element is anywhere near the viewport. Each write costs a style recalculation. Measured on wabbit.com: the ContentWithMarquee band sits ~4400px below the fold on `/architecture-sprint`, and on a freshly-loaded page that nobody had scrolled it was still rewriting its transform ~119 times a second. That drove ~144 style recalcs/s and ~8% of a core, indefinitely, and is what produced user reports of Chrome's "this tab is slowing your browser" prompt. A CPU profile of the same idle page came back 93.8% idle with no JS function above 1% — the cost is entirely style recalculation, not script, which is why it is easy to miss. `useInViewport` (new, IntersectionObserver-backed) now gates `useTicker`'s `enabled`. On-screen behavior is unchanged; a `200px` rootMargin starts the loop just before the band scrolls in so it is never seen starting from a dead stop. The hook defaults to `true` and bails out where IntersectionObserver is unavailable, so the degraded path is "animates" (today's behavior) rather than a silently frozen marquee. Note for anyone touching this: `inViewport` must be passed to `useTicker` as BOTH `enabled` and a member of `dependencies`. `useTicker` reads `enabled` inside a `useGSAP` effect keyed on that array, so omitting it leaves the gate frozen at its mount-time value and the block animates off-screen exactly as before — with no type error and no runtime symptom short of profiling. A source-level audit in `test/marquee-viewport-gate.test.ts` pins the invariant (verified to fail when the dependency is removed).
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0
v0.13.0minor

eb403d4: section-header: variant-aware (default / statement-hero / ruled-split) — statement-hero + ruled-split graduated from tome-starter (showcase Phase 3.7); statementLines on base schema. `default` is the classic badge/title/description split header (unchanged, pixel-identical for existing documents). `statement-hero` is an oversized serif statement with staggered line-rise entrance, kicker label + aside description column. `ruled-split` is the editorial section scaffold: hairline top rule, mono kicker, serif title left / running body right (5/7 split), scroll-reveal via the `@wabbit/tome-blocks-core` `Reveal` helper. Adds a `statementLines` array field (statement-hero only) to the base schema — additive, backward-compatible. Both new variants are style-only: `statementLines` lives on the base schema behind an `admin.condition`, not a `fieldOverrides` schema variant, because per-document schema divergence can't be expressed at config-build time (see comment in `section-header.ts`).

  • eb403d4: section-header: variant-aware (default / statement-hero / ruled-split) — statement-hero + ruled-split graduated from tome-starter (showcase Phase 3.7); statementLines on base schema. `default` is the classic badge/title/description split header (unchanged, pixel-identical for existing documents). `statement-hero` is an oversized serif statement with staggered line-rise entrance, kicker label + aside description column. `ruled-split` is the editorial section scaffold: hairline top rule, mono kicker, serif title left / running body right (5/7 split), scroll-reveal via the `@wabbit/tome-blocks-core` `Reveal` helper. Adds a `statementLines` array field (statement-hero only) to the base schema — additive, backward-compatible. Both new variants are style-only: `statementLines` lives on the base schema behind an `admin.condition`, not a `fieldOverrides` schema variant, because per-document schema divergence can't be expressed at config-build time (see comment in `section-header.ts`).
  • Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2
v0.11.0patch

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: 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • 5f78397: Server-safe adapter contract (spec 2026-07-12, waves M0–M1). blocks-core gains `./adapters`: `registerBlockAdapters({ richText?, media? })` (explicit, idempotent, lazily globalThis-anchored — layerRegistry pattern) plus environment-agnostic `resolveRichText(value, opts?)` / `resolveMedia(value, opts?)` callable from RSC and client alike. The adapter React Contexts now live in blocks-core (`adapters/context.tsx`); blocks-extras' adapter modules are thin re-exports (zero API break) and its Providers additionally sync their adapter into the registry (guarded write-during-render, documented). Unregistered-registry resolution returns a client fallback element that reads the Context — provider-based sites see zero behavior change even when migrated blocks execute as Server Components; sites that call `registerBlockAdapters` from a module in both graphs get pure server rendering. Migration contract for consumers: call `registerBlockAdapters` at config/app scope when adopting RSC-rendered blocks; the `useRichTextAdapter`/`useMediaAdapter` hooks remain functional (deprecated-in-place; removal trigger in the spec).
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
  • 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.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0
v0.9.5patch

Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.

  • Demo-kit diversification (10 fictional brands across all demo props) and block fixes: pricing/testimonial demos supply real card objects instead of placeholder-ID strings; PostHero/EditorialOpener/BlogHero/ChapterHero format display dates with a fixed locale (ISO preserved in the time dateTime attribute); PostHero background layer no longer collapses to the content row (abs-pos grid-item containing-block fix) and fills via the Media adapter; Testimonial renders plain-string quotes.
v0.9.4patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4
v0.9.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2
v0.9.1patch

c07f3c8: Fix layoutGrid row-spanning ("tall") cells rendering at content height instead of filling their multi-row area. `LayoutGrid.module.css` set `align-items: start` on the grid, which sizes every cell to its content — so a `grid-row: span 2` cell (the tall image in the "two-up + tall image" and "feature + sidebar" starter templates) occupied its 2-row area but rendered at one-row height. The row-span was structurally correct (spans emitted, pairing sound) but visually inert. Adds `align-self: stretch` to row-spanning cells (targeted via the inline `grid-row` marker, the same hook the phone reflow uses) so they fill their area; non-spanning cells keep their natural height. Found via a published-artifact render dogfood.

  • c07f3c8: Fix layoutGrid row-spanning ("tall") cells rendering at content height instead of filling their multi-row area. `LayoutGrid.module.css` set `align-items: start` on the grid, which sizes every cell to its content — so a `grid-row: span 2` cell (the tall image in the "two-up + tall image" and "feature + sidebar" starter templates) occupied its 2-row area but rendered at one-row height. The row-span was structurally correct (spans emitted, pairing sound) but visually inert. Adds `align-self: stretch` to row-spanning cells (targeted via the inline `grid-row` marker, the same hook the phone reflow uses) so they fill their area; non-spanning cells keep their natural height. Found via a published-artifact render dogfood.
v0.9.0minor

c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.

  • c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0
v0.8.0minor

249b670: Batch 1 hero consolidation (2026-06-27 inserter/variant architecture) — collapse scattered hero blocks into variant-driven parents (rendered by the Batch-0 VariantPicker), shrinking the Hero category in the inserter/gallery. - **New `article-hero`** — consolidates the field-identical `blog-hero` + `chapter-hero` into one block with `post` (default) / `chapter` variants. Each variant dispatches to its existing render (visuals preserved); the Batch-0 `{variant→component}` registry is populated. - **New `hero-pro`** — consolidates `typography-hero` + `image-hero` into one premium block with `typography` (default) / `image` variants. The two have near-disjoint fields, so each variant's fields are conditioned on `_variant` (live discriminated union — no save/reload); renders dispatch to the existing TypographyHero / ImageHero. - **Feature dedupe** — `feature-hero-with-cards` + `feature-with-icon-grid` deprecated → the marketing-starter `featureHero` (its inline variant select already unifies hero-with-cards + icon-grid). The broader feature-family consolidation is Batch 2. - **Deprecated** (still registered + rendered for back-compat, removed from the offered `extras` bundle + client-safe gallery meta): `blog-hero`, `chapter-hero` → article-hero; `typography-hero`, `image-hero` → hero-pro; `low-impact-hero`, `medium-impact-hero` → the free marketing `hero`; `feature-hero-with-cards`, `feature-with-icon-grid` → featureHero. Instance migration → successor + `_variant` and full removal are deferred to a later release (a live migration run, then the next pack major). Authored usage/intent metadata on both new blocks. Ships in the linked family's 0.8.0 minor. Typecheck + build green.

  • 249b670: Batch 1 hero consolidation (2026-06-27 inserter/variant architecture) — collapse scattered hero blocks into variant-driven parents (rendered by the Batch-0 VariantPicker), shrinking the Hero category in the inserter/gallery. - **New `article-hero`** — consolidates the field-identical `blog-hero` + `chapter-hero` into one block with `post` (default) / `chapter` variants. Each variant dispatches to its existing render (visuals preserved); the Batch-0 `{variant→component}` registry is populated. - **New `hero-pro`** — consolidates `typography-hero` + `image-hero` into one premium block with `typography` (default) / `image` variants. The two have near-disjoint fields, so each variant's fields are conditioned on `_variant` (live discriminated union — no save/reload); renders dispatch to the existing TypographyHero / ImageHero. - **Feature dedupe** — `feature-hero-with-cards` + `feature-with-icon-grid` deprecated → the marketing-starter `featureHero` (its inline variant select already unifies hero-with-cards + icon-grid). The broader feature-family consolidation is Batch 2. - **Deprecated** (still registered + rendered for back-compat, removed from the offered `extras` bundle + client-safe gallery meta): `blog-hero`, `chapter-hero` → article-hero; `typography-hero`, `image-hero` → hero-pro; `low-impact-hero`, `medium-impact-hero` → the free marketing `hero`; `feature-hero-with-cards`, `feature-with-icon-grid` → featureHero. Instance migration → successor + `_variant` and full removal are deferred to a later release (a live migration run, then the next pack major). Authored usage/intent metadata on both new blocks. Ships in the linked family's 0.8.0 minor. Typecheck + build green.
  • 249b670: Batch 2 feature consolidation (2026-06-27 inserter/variant architecture) — collapse the scattered feature blocks into the single `featureHero`. - **Deprecated** (still registered + rendered for back-compat, removed from the offered `extras` bundle + client-safe gallery meta): `feature-masonry`, `feature-with-large-media`, `feature-with-three-steps`, `media-feature` → the marketing-starter `featureHero`. Together with Batch 1's `feature-hero-with-cards` + `feature-with-icon-grid`, the whole feature family now consolidates to `featureHero`, whose 6-layout `variant` select already covers them all. - **Authored usage/intent metadata** on `featureHero` (now the sole offered feature block). - **Deferred to the live-run reconciliation pass** (brand-preserving but a field-semantics swap + data migration): `featureHero`'s dual variant mechanism — make the 6 LAYOUTS the `_variant` (so the VariantPicker drives layout, not the unrelated 4-value style axis), move the style axis to a secondary field, and drop the inline `variant`. Instance migration (standalone feature blocks → `featureHero` + the right layout) is deferred to a later release. Tier note: the feature layouts consolidate onto the FREE `featureHero` (it already carried all 6 free); unlike heroes there is no distinct premium feature layout to gate, so there is no paid `feature-pro`. Ships in the linked family's 0.8.0 minor.
  • 249b670: Batch 3 content consolidation (2026-06-27 inserter/variant architecture). Ground-truth found the content family has THREE incompatible data shapes, so it does NOT collapse to one block. Instead: - **New paid `content-pro`** consolidates the four single-body-plus-decoration blocks into one block with `prose` (default) / `post` / `marquee` / `bento` variants — the hero-pro discriminated-union pattern (each variant's fields conditioned on `_variant`; renders dispatch to the existing ProseSection / PostContent / ContentWithMarquee / ContentWithBento). Authored usage/intent metadata. - **Deprecated** (still registered + rendered for back-compat, removed from the offered `extras` bundle + client-safe gallery meta): `prose-section`, `post-content`, `content-with-marquee`, `content-with-bento` → `content-pro`. Instance migration → `contentPro` + `_variant` is deferred to a later release (a live migration run). - **Kept distinct** (incompatible shapes / different register): free `content` (14-column grid), free `content-two-column`, free `text-block`, free `section`, and paid `content-with-corner-notch` (its premium notch apparatus is not a free variant). `editorialSpread`/`editorialSection` already carry variants and just gain the wired VariantPicker — no change. Three top-level field names shared by two variants each (`richText` = prose+marquee, `sectionTitle` + `mainContent` = post+bento) were reconciled to one shared field shown for both owning variants (the dispatched render reads it by name). Deferred to the live-run reconciliation pass: rename prose-section's inline `variant` (centered|sidebar) field so it no longer reads as a second variant axis next to `_variant`. Ships in the linked family's 0.8.0 minor.
  • Updated dependencies [249b670] - @wabbit/tome-blocks-core@0.8.0
v0.7.0minor

28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.

  • 28802fa: Expose a client-safe `./demo` export (the already-built, payload-free `getDemoProps` module) on each block pack, separate from the payload-importing root barrel. The root barrel (`.`) eagerly pulls each block's config, which imports `payload` (→ `richtext-lexical` → `pino` → `worker_threads`). A consumer that registers packs **client-side** (the wabbit `/blocks` gallery storefront, B6) can't import `getDemoProps` from the root barrel without dragging `payload`/`worker_threads` into the browser bundle (build failure). The `dist/demo.*` module is already built and contains only demo-data + type imports — this change just makes it importable as `@wabbit/tome-blocks-<pack>/demo`. Additive; no code or runtime change to the packs. NOTE: this is the `getDemoProps` half of the client-safe gallery-registration fix. The companion piece — a client-safe **block-meta/descriptor** export (slug/label/variants/tier, separate from the payload-importing config the gallery bridges currently read `.meta` off) — is still needed before B6 can register packs entirely off the root barrel. Tracked separately.
  • 66c611c: B1 — block tiering formalization + blocks-extras free/paid split. - **blocks-core:** add `'addon'` to the `BundleMeta.tier` union (additive; existing `'pro'` values stay valid). - **blocks-extras:** register TWO bundles from one package (Option B) — a free `extras-primitives` sub-bundle (9 structural primitives) + the paid `extras` remainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existing `extras` entitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - **signal-theme + sc-pack:** tier `'pro'` → `'addon'` (sold independently of the tiered subscription track). - **blocks-gallery:** widen the `@wabbit/tome-blocks-core` peer to `^0.5.9 || ^0.6.0 || ^0.7.0` so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch. Release note: the blocks family is `linked`, so this aligns the whole family to **0.7.0**. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (`^0.6` does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.
  • 8958d41: Expose a client-safe `./meta` export on each block pack: payload-free block descriptor metadata (slug/name/description/category/tags/source + variants), separate from the payload-importing root barrel. This is the companion to the `./demo` export. Each block's `meta` literal is now extracted into a co-located payload-free `meta` module that the block config imports, and a pack-level `./meta` entry exposes the full descriptor list as `<pack>BlockMeta`. A consumer registering packs client-side (the wabbit `/blocks` gallery storefront, B6) can now read block metadata for gallery entries without importing the root barrel, which eagerly pulls each block's config (`payload` -> `richtext-lexical` -> `pino` -> `worker_threads`) into the browser bundle. Additive and behavior-preserving: `defineBlock` receives the same meta object (now imported rather than inline); the block registry, configs, demos, and existing exports are unchanged. The pack `BlockMeta` array is also re-exported from the root barrel for path-alias consumers.
  • Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2
v0.5.9patch

@wabbit/tome-blocks-core@0.5.9

  • @wabbit/tome-blocks-core@0.5.9
v0.5.7patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
v0.5.0minor

`pullQuote`: add a `voice-band` variant — full-bleed eggplant moment quote, large display-italic, token-driven.

  • `pullQuote`: add a `voice-band` variant — full-bleed eggplant moment quote, large display-italic, token-driven.
v0.4.2patch

**Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.

  • **Re-publish: rewrite `workspace:*` to actual semver in dependencies.** Earlier 0.4.0/0.4.1 publishes used `npm publish` directly, which doesn't rewrite `workspace:*` deps. Verdaccio captured the literal `"workspace:*"` strings in the published `package.json` `dependencies` fields, so npm consumers (e.g. wabbit-site-core) failed to install with `EUNSUPPORTEDPROTOCOL workspace:*`. This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses `pnpm publish` which rewrites `workspace:_` to the actual version of the workspace dep at publish time. No source changes — purely a publish-pipeline correction.
  • Updated dependencies - @wabbit/tome-blocks-core@0.4.2
v0.4.1patch

**CardBlock renderer: tolerate consumer-overridden link field naming.** `@wabbit/tome-core/fields/link` exposes a `naming` option that lets consumers override the URL field name (e.g. Wabbit uses `externalUrl` instead of `url`). The pack's CardBlock renderer was reading `block.cardLink?.url` directly, so consumers using non-default naming saw broken full-card links and broken button hrefs. Fix: introduce a `linkHref()` helper that reads `link.url || link.externalUrl`, and route both the `cardLink` (full-card) and `buttons[].link` reads through it. Same pattern can be extended to other pack renderers (CardGrid, ContentWithBento, BlogHero, ChapterHero, CustomHero, ContentWithMarquee, Feature, DataHero, etc.) in a follow-up — that scope is broader and not blocking the immediate Phase B swap target. Discovered during the Wabbit ↔ tome-blocks alignment audit (2026-04-28) Phase B parity check: BentoSection and SkillShowcase swapped clean once adapters shipped, but CardBlock's link mismatch required this companion fix. - @wabbit/tome-blocks-core@0.4.0

  • **CardBlock renderer: tolerate consumer-overridden link field naming.** `@wabbit/tome-core/fields/link` exposes a `naming` option that lets consumers override the URL field name (e.g. Wabbit uses `externalUrl` instead of `url`). The pack's CardBlock renderer was reading `block.cardLink?.url` directly, so consumers using non-default naming saw broken full-card links and broken button hrefs. Fix: introduce a `linkHref()` helper that reads `link.url || link.externalUrl`, and route both the `cardLink` (full-card) and `buttons[].link` reads through it. Same pattern can be extended to other pack renderers (CardGrid, ContentWithBento, BlogHero, ChapterHero, CustomHero, ContentWithMarquee, Feature, DataHero, etc.) in a follow-up — that scope is broader and not blocking the immediate Phase B swap target. Discovered during the Wabbit ↔ tome-blocks alignment audit (2026-04-28) Phase B parity check: BentoSection and SkillShowcase swapped clean once adapters shipped, but CardBlock's link mismatch required this companion fix. - @wabbit/tome-blocks-core@0.4.0
v0.4.0minor

90a694d: **Add `richText` and `media` adapter layer to `@wabbit/tome-blocks-extras`, mirroring the existing motion adapter pattern.** Pack renderers across `blocks-extras`, `blocks-marketing-starter`, `blocks-content-writer`, `blocks-agency-essentials`, `blocks-editorial-pack`, `blocks-signal-theme`, and `blocks-sc-pack` now consume: - `useRichTextAdapter()` — returns `{ RichText, isRichTextActive }`. The `RichText` component renders Lexical/Payload rich-text JSON to JSX. Default noop renders a `<div data-rich-text data-rich-text-stub />` placeholder so unwired consumers see the same behavior they did before this PR; wired consumers (e.g. Wabbit) inject their own `RichText` impl. - `useMediaAdapter()` — returns `{ Media, isMediaActive }`. The `Media` component renders Payload media references. Default noop renders raw `<img>` (preserving prior behavior); wired consumers inject Next/Image-aware Media impls with CDN sizing, fill, etc. **New public API:** - Subpath exports: `@wabbit/tome-blocks-extras/adapters/richText`, `@wabbit/tome-blocks-extras/adapters/media` - Hooks: `useRichTextAdapter`, `useMediaAdapter` - Providers: `RichTextAdapterProvider`, `MediaAdapterProvider` - Types: `BlocksExtrasRichTextAdapter`, `BlocksExtrasMediaAdapter`, `RichTextProps`, `MediaProps`, `MediaResource` - Noop singletons: `NOOP_RICH_TEXT_ADAPTER`, `NOOP_MEDIA_ADAPTER` **Pack renderer migrations:** every `<div data-rich-text />` placeholder is replaced with `<RichText data={...} />`. Every Payload-resolved-media `<img>` is replaced with `<Media resource={...} />`. The 3 hero-marquee renderers (Marquee, ImageMarquee, LogoSlider) carry pre-existing inline fallback behavior and are unaffected. **Why this matters:** previously, pack renderers shipped as visual skeletons — they rendered correctly only for blocks that didn't carry rich text or Payload-resolved media. Consumer sites adopting pack renderers for blocks like BentoSection, CardBlock, SkillShowcase saw empty `<div>` body content and lost image optimization. This PR makes the entire pack catalog usable as drop-in renderers for any consumer that mounts the two adapter providers. **Cross-pack dependency:** the 5 sibling packs (signal-theme, sc-pack, content-writer, agency-essentials, editorial-pack) now declare `@wabbit/tome-blocks-extras` as a workspace dep — mirroring blocks-marketing-starter's existing motion-adapter consumption pattern. **Discovered during** the Wabbit ↔ tome-blocks alignment audit (2026-04-28). Consumer-side adapter providers and integration are the next step (Wabbit and Starter will mount providers wired to their existing RichText + Media components).

  • 90a694d: **Add `richText` and `media` adapter layer to `@wabbit/tome-blocks-extras`, mirroring the existing motion adapter pattern.** Pack renderers across `blocks-extras`, `blocks-marketing-starter`, `blocks-content-writer`, `blocks-agency-essentials`, `blocks-editorial-pack`, `blocks-signal-theme`, and `blocks-sc-pack` now consume: - `useRichTextAdapter()` — returns `{ RichText, isRichTextActive }`. The `RichText` component renders Lexical/Payload rich-text JSON to JSX. Default noop renders a `<div data-rich-text data-rich-text-stub />` placeholder so unwired consumers see the same behavior they did before this PR; wired consumers (e.g. Wabbit) inject their own `RichText` impl. - `useMediaAdapter()` — returns `{ Media, isMediaActive }`. The `Media` component renders Payload media references. Default noop renders raw `<img>` (preserving prior behavior); wired consumers inject Next/Image-aware Media impls with CDN sizing, fill, etc. **New public API:** - Subpath exports: `@wabbit/tome-blocks-extras/adapters/richText`, `@wabbit/tome-blocks-extras/adapters/media` - Hooks: `useRichTextAdapter`, `useMediaAdapter` - Providers: `RichTextAdapterProvider`, `MediaAdapterProvider` - Types: `BlocksExtrasRichTextAdapter`, `BlocksExtrasMediaAdapter`, `RichTextProps`, `MediaProps`, `MediaResource` - Noop singletons: `NOOP_RICH_TEXT_ADAPTER`, `NOOP_MEDIA_ADAPTER` **Pack renderer migrations:** every `<div data-rich-text />` placeholder is replaced with `<RichText data={...} />`. Every Payload-resolved-media `<img>` is replaced with `<Media resource={...} />`. The 3 hero-marquee renderers (Marquee, ImageMarquee, LogoSlider) carry pre-existing inline fallback behavior and are unaffected. **Why this matters:** previously, pack renderers shipped as visual skeletons — they rendered correctly only for blocks that didn't carry rich text or Payload-resolved media. Consumer sites adopting pack renderers for blocks like BentoSection, CardBlock, SkillShowcase saw empty `<div>` body content and lost image optimization. This PR makes the entire pack catalog usable as drop-in renderers for any consumer that mounts the two adapter providers. **Cross-pack dependency:** the 5 sibling packs (signal-theme, sc-pack, content-writer, agency-essentials, editorial-pack) now declare `@wabbit/tome-blocks-extras` as a workspace dep — mirroring blocks-marketing-starter's existing motion-adapter consumption pattern. **Discovered during** the Wabbit ↔ tome-blocks alignment audit (2026-04-28). Consumer-side adapter providers and integration are the next step (Wabbit and Starter will mount providers wired to their existing RichText + Media components).
  • Updated dependencies [b76f684] - @wabbit/tome-blocks-core@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Industrial Theme

v0.2.3
v0.2.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.2.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.0minor

382f36c: **BREAKING:** `INDUSTRIAL_THEME` is now a `ThemeManifest` (`kind: 'treatment'`), and a new `./register` subpath adds it to the Tome theme registry. **Migration:** read `name` instead of `themeAttribute` (both `'industrial'`) and `label` instead of `name`; `slug` and `tags` are gone (the package keywords carry the tags). `styledBlocks` is unchanged. `ThemeMeta` is a deprecated alias of `ThemeManifest` for one minor. To list the treatment in the admin theme switcher, add `import '@wabbit/tome-blocks-industrial-theme/register'` on the server (needs the optional `@wabbit/tome-core` `>=1.20.0` peer). - The stylesheet moved to `treatments/catalog-lms.css`; `./styles.css` imports it, so the import path is unchanged. - The course card's white background now reads a private `--industrial-surface` token (from `--pc-white`, default `#fff`) instead of a hard-coded literal; rendering is unchanged unless a site defines `--pc-white`. - The test suite runs the theme conformance check for treatments: every selector scoped to the theme attribute, public hooks only, and no raw color literal outside the private `--industrial-*` tokens.

  • 382f36c: **BREAKING:** `INDUSTRIAL_THEME` is now a `ThemeManifest` (`kind: 'treatment'`), and a new `./register` subpath adds it to the Tome theme registry. **Migration:** read `name` instead of `themeAttribute` (both `'industrial'`) and `label` instead of `name`; `slug` and `tags` are gone (the package keywords carry the tags). `styledBlocks` is unchanged. `ThemeMeta` is a deprecated alias of `ThemeManifest` for one minor. To list the treatment in the admin theme switcher, add `import '@wabbit/tome-blocks-industrial-theme/register'` on the server (needs the optional `@wabbit/tome-core` `>=1.20.0` peer). - The stylesheet moved to `treatments/catalog-lms.css`; `./styles.css` imports it, so the import path is unchanged. - The course card's white background now reads a private `--industrial-surface` token (from `--pc-white`, default `#fff`) instead of a hard-coded literal; rendering is unchanged unless a site defines `--pc-white`. - The test suite runs the theme conformance check for treatments: every selector scoped to the theme attribute, public hooks only, and no raw color literal outside the private `--industrial-*` tokens.
v0.1.2patch

917b77a: CSS files are copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook, whose output the type-declaration phase can remove.

  • 917b77a: CSS files are copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook, whose output the type-declaration phase can remove.
v0.1.1patch

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.

  • 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.
  • 637db74: README brought to the package contract (install, Public API, posture, decisions) and a node:test smoke suite added (descriptor/manifest tier agreement, every claimed block has a scoped rule, no selector escapes the theme scope). Closes the two CI gate failures the package shipped with.
  • 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.
v0.1.0minor

Initial release: industrial theme pack — safety-yellow accents, charcoal type, motorsport-italic chrome for catalog/LMS blocks. Extracted from the client-era styling that used to ship inside blocks-lms-pack (CourseCard) and blocks-catalog-pack (ProductGrid, CategoryStrip). Owns no blocks; restyles the neutral packs through their public .tome-_ BEM hooks, scoped under [data-tome-theme='industrial'] so it wins on specificity regardless of stylesheet order and can be applied per-section. Prefers legacy --pc-_ tokens when the consumer defines them.

  • Initial release: industrial theme pack — safety-yellow accents, charcoal type, motorsport-italic chrome for catalog/LMS blocks. Extracted from the client-era styling that used to ship inside blocks-lms-pack (CourseCard) and blocks-catalog-pack (ProductGrid, CategoryStrip). Owns no blocks; restyles the neutral packs through their public .tome-_ BEM hooks, scoped under [data-tome-theme='industrial'] so it wins on specificity regardless of stylesheet order and can be applied per-section. Prefers legacy --pc-_ tokens when the consumer defines them.

Blocks Directory Pack

v1.9.1
v1.9.1patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v1.9.0minor

1619d8c: **BREAKING:** the `/maps` providers now wrap the neutral providers in `@wabbit/tome-maps`, a new required peer. **Migration:** install `@wabbit/tome-maps` alongside this pack. Every existing import path and export keeps working, and a directory map still renders nothing and loads no map SDK unless `gatePassed` is `true`. The pack keeps its own provider registry, so only the age-gated providers are ever reachable from a directory block.

  • 1619d8c: **BREAKING:** the `/maps` providers now wrap the neutral providers in `@wabbit/tome-maps`, a new required peer. **Migration:** install `@wabbit/tome-maps` alongside this pack. Every existing import path and export keeps working, and a directory map still renders nothing and loads no map SDK unless `gatePassed` is `true`. The pack keeps its own provider registry, so only the age-gated providers are ever reachable from a directory block.
v1.8.0minor

a936741: Listing photos now render when they arrive as populated Media documents, and the listing grid can hide its distance. - **Fix: card and hero photos printed `[object Object]`.** The listing grid card and the listing detail hero put `photos[0]` straight into `<img src>`. A listing read with `findListingsNear` (depth 1) carries populated Media documents, so the image broke. Both renderers now accept either a URL string or a Media document: the `url`, else the widest entry in `sizes` that has one, with the document's `alt` as the image's alt text. A string photo renders exactly as before, with empty alt, and a document with no usable URL shows the pack's fallback frame instead of a broken image. The other directory blocks (map, promotions rail, mega menu) never printed listing photos and are unchanged. - **New `showDistance` on `directoryListingGrid`.** A checkbox, default on. Turn it off for a market where distance means nothing (a one-courtyard market prints "0.0 mi" on every card): the cards drop the distance, and the Distance sort link is removed from the sort bar. Unset or on, the markup is byte-identical to before. This brings a sliver of the planned grid distance controls into the first release. The new `showDistance` field adds one nullable column: on a SQL database, regenerate your migration.

  • a936741: Listing photos now render when they arrive as populated Media documents, and the listing grid can hide its distance. - **Fix: card and hero photos printed `[object Object]`.** The listing grid card and the listing detail hero put `photos[0]` straight into `<img src>`. A listing read with `findListingsNear` (depth 1) carries populated Media documents, so the image broke. Both renderers now accept either a URL string or a Media document: the `url`, else the widest entry in `sizes` that has one, with the document's `alt` as the image's alt text. A string photo renders exactly as before, with empty alt, and a document with no usable URL shows the pack's fallback frame instead of a broken image. The other directory blocks (map, promotions rail, mega menu) never printed listing photos and are unchanged. - **New `showDistance` on `directoryListingGrid`.** A checkbox, default on. Turn it off for a market where distance means nothing (a one-courtyard market prints "0.0 mi" on every card): the cards drop the distance, and the Distance sort link is removed from the sort bar. Unset or on, the markup is byte-identical to before. This brings a sliver of the planned grid distance controls into the first release. The new `showDistance` field adds one nullable column: on a SQL database, regenerate your migration.
  • 8d7c046: Two markup changes for themes, one of them an accessibility fix for every directory site. - **Listing grid cards** carry `data-open-state` (`open`, `closed` or `unknown`), from the same open state that prints the "Open now" line; `unknown` when the listing has no boolean open state. Nothing else on the card changes. - **Promotions rail: the list is a focusable, labeled scroll region.** `ul[data-promotions-list]` now has `tabindex="0"`, `role="region"` and an `aria-label`, so a list that scrolls can be reached and scrolled from the keyboard. The markup cannot know whether a theme's CSS makes the list scroll, so it is always set: where the list does not scroll it is one extra Tab stop. `role="region"` replaces the list role, so assistive technology announces a labeled region rather than a list. The pack's stylesheet adds a `:focus-visible` ring. The name is the new optional `labels.railRegion` (default "Promotions, scrollable list"). On a SQL database the new `labels` group on `directoryPromotionsRail` adds one nullable column when a site regenerates its migrations; nothing existing changes. This is the one deliberate markup change to the rail: the empty state is unchanged. - The open-state line on each listing card also carries `data-listing-state` (`open`, `closed` or `unknown`), so a theme can style that line as a chip by hook.
  • 024edf0: Internal Tome dependencies are published as compatible ranges, not exact versions, so installs share one copy of tome-core. Before, these packs pinned the exact tome-core version that was current when they shipped, so an install could end up with two copies of tome-core and split its shared registries. They now declare a compatible `^` range that npm can resolve to a single copy.
v1.7.9patch

1636ffa: Demo links no longer invent site paths. The `directoryListingGrid` demo's detail route and base path, and the `directoryMegaMenu` demo's market links, are the caller's `placeholderHref` or `#`. `directoryMegaMenu` markets take an optional `href`; without one a market still links to `/markets/<marketSlug>`.

  • 1636ffa: Demo links no longer invent site paths. The `directoryListingGrid` demo's detail route and base path, and the `directoryMegaMenu` demo's market links, are the caller's `placeholderHref` or `#`. `directoryMegaMenu` markets take an optional `href`; without one a market still links to `/markets/<marketSlug>`.
v1.7.8patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v1.7.7
v1.7.6
v1.7.5
v1.7.4patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v1.7.3
v1.7.2
v1.7.1patch

6216174: The listing grid no longer drags the directory package root into client bundles, and links that share a URL no longer collide on React keys. `DirectoryListingGrid` reads the listing-type registry from `@wabbit/tome-directory/listing-types`, so importing `./render` from client code no longer pulls in payload and pino. The age gate's resource links and the listing detail's outbound links are keyed by their row `id`, else by position and url, so several links sharing `#` render cleanly. Requires `@wabbit/tome-directory` 1.9.0 or later.

  • 6216174: The listing grid no longer drags the directory package root into client bundles, and links that share a URL no longer collide on React keys. `DirectoryListingGrid` reads the listing-type registry from `@wabbit/tome-directory/listing-types`, so importing `./render` from client code no longer pulls in payload and pino. The age gate's resource links and the listing detail's outbound links are keyed by their row `id`, else by position and url, so several links sharing `#` render cleanly. Requires `@wabbit/tome-directory` 1.9.0 or later.
v1.7.0minor

cbfabd9: The age gate can no longer strand a visitor on a disabled "Entering…" button. - New `redirectTo` prop: a same-origin path the gate loads with a full document navigation after a passing verify (after `onVerified`). Values that are not a single-slash path, such as `//host` or absolute URLs, are ignored, so a `redirectTo` built from `?next=` cannot become an open redirect. - If the page is still showing the gate `proceedStallMs` (default 6s) after a pass, the button re-enables and a notice offers a plain Continue link (when `redirectTo` is set) or asks the visitor to press Enter again. - Docs: a host that navigates in `onVerified` must use a full document load. A client-router push can replay a prefetched redirect back to the gate, which is how a live site left visitors stuck after entering their date of birth.

  • cbfabd9: The age gate can no longer strand a visitor on a disabled "Entering…" button. - New `redirectTo` prop: a same-origin path the gate loads with a full document navigation after a passing verify (after `onVerified`). Values that are not a single-slash path, such as `//host` or absolute URLs, are ignored, so a `redirectTo` built from `?next=` cannot become an open redirect. - If the page is still showing the gate `proceedStallMs` (default 6s) after a pass, the button re-enables and a notice offers a plain Continue link (when `redirectTo` is set) or asks the visitor to press Enter again. - Docs: a host that navigates in `onVerified` must use a full document load. A client-router push can replay a prefetched redirect back to the gate, which is how a live site left visitors stuck after entering their date of birth.
v1.6.1patch

e2cfcf0: Accessibility and sign-in fixes for the directory pack. - Map markers (pins and clusters, all three providers) now guarantee a hit area of at least 24x24 CSS px (WCAG 2.2 SC 2.5.8), and 44x44 on coarse (touch) pointers, via transparent padding on the marker's inner wrapper. The drawn pin, its anchor tip, keyboard focus, and the re-asserted `aria-label` are unchanged. - `DirectoryReviewForm`: `signInHref` now defaults to `/login` (Tome Starter's route; `/sign-in` did not exist) with `?redirect=<current page>` appended. A `signInHref` you pass is used verbatim. `isLoggedIn` still defaults to `true` (changing it would break consumers that rely on it), but a 401/403 from the default submit path, or `authRequired: true` from a custom `submitAction`, now swaps the form to the sign-in prompt instead of a dead error. Consumers that set `requireLogin` should still pass the real `isLoggedIn`.

  • e2cfcf0: Accessibility and sign-in fixes for the directory pack. - Map markers (pins and clusters, all three providers) now guarantee a hit area of at least 24x24 CSS px (WCAG 2.2 SC 2.5.8), and 44x44 on coarse (touch) pointers, via transparent padding on the marker's inner wrapper. The drawn pin, its anchor tip, keyboard focus, and the re-asserted `aria-label` are unchanged. - `DirectoryReviewForm`: `signInHref` now defaults to `/login` (Tome Starter's route; `/sign-in` did not exist) with `?redirect=<current page>` appended. A `signInHref` you pass is used verbatim. `isLoggedIn` still defaults to `true` (changing it would break consumers that rely on it), but a 401/403 from the default submit path, or `authRequired: true` from a custom `submitAction`, now swaps the form to the sign-in prompt instead of a dead error. Consumers that set `requireLogin` should still pass the real `isLoggedIn`.
v1.6.0minor

6ee7fe4: Directory maps now use cooperative gestures by default: on touch, one finger scrolls the page and two fingers move the map; on desktop, wheel zoom needs Ctrl/Cmd. A map embedded in a scrolling page no longer traps a phone user who scrolls past it. MapLibre and Mapbox get `cooperativeGestures: true`, Google gets `gestureHandling: 'cooperative'`. Pass `cooperativeGestures: false` on the map view props (MapView, mountMaplibreMap, mountMapboxMap, mountGoogleMap) for a map that fills the viewport. Non-interactive maps are unaffected. Also fixes an orphaned map in every provider's MapView: when the component unmounted or re-ran while the SDK was still loading, the map that finished loading afterwards was never destroyed, leaving a second canvas and duplicate markers in the container.

  • 6ee7fe4: Directory maps now use cooperative gestures by default: on touch, one finger scrolls the page and two fingers move the map; on desktop, wheel zoom needs Ctrl/Cmd. A map embedded in a scrolling page no longer traps a phone user who scrolls past it. MapLibre and Mapbox get `cooperativeGestures: true`, Google gets `gestureHandling: 'cooperative'`. Pass `cooperativeGestures: false` on the map view props (MapView, mountMaplibreMap, mountMapboxMap, mountGoogleMap) for a map that fills the viewport. Non-interactive maps are unaffected. Also fixes an orphaned map in every provider's MapView: when the component unmounted or re-ran while the SDK was still loading, the map that finished loading afterwards was never destroyed, leaving a second canvas and duplicate markers in the container.
v1.5.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v1.5.1patch

483e0a1: The directory pack's preview listings now use a fictional plumbing business name. The listing grid, listing detail and map previews use an invented business and slug. Block fields and variants are unchanged.

  • 483e0a1: The directory pack's preview listings now use a fictional plumbing business name. The listing grid, listing detail and map previews use an invented business and slug. Block fields and variants are unchanged.
v1.5.0minor

e5ee256: `trackDirectoryEvent` and `DirectoryClickTrack` can now report an `outbound` click with a `kind`, and `source` and `surface` accept any registered key. Existing events are sent unchanged. An `outbound` event without a `kind` is not sent, because the ingestion endpoint could not count it. The `source` and `surface` types widen to `Known | (string & {})`, so a site can send the extra view sources and sponsored surfaces it registers in `@wabbit/tome-directory`'s `analytics` config. Sending the new `outbound` type needs `@wabbit/tome-directory` 1.6.0 on the server; an older server drops it.

  • e5ee256: `trackDirectoryEvent` and `DirectoryClickTrack` can now report an `outbound` click with a `kind`, and `source` and `surface` accept any registered key. Existing events are sent unchanged. An `outbound` event without a `kind` is not sent, because the ingestion endpoint could not count it. The `source` and `surface` types widen to `Known | (string & {})`, so a site can send the extra view sources and sponsored surfaces it registers in `@wabbit/tome-directory`'s `analytics` config. Sending the new `outbound` type needs `@wabbit/tome-directory` 1.6.0 on the server; an older server drops it.
v1.4.4
v1.4.3
v1.4.2patch

f4cd37c: The listing detail hours table now shows every opening window for a day. A day with split-shift hours (for example 09:00 – 13:00 and 17:00 – 22:00) previously showed only one window, because the table kept a single entry per weekday. It now lists all of them, and shows Closed only when a day has no open entries, matching `@wabbit/tome-directory`'s open-now rule.

  • f4cd37c: The listing detail hours table now shows every opening window for a day. A day with split-shift hours (for example 09:00 – 13:00 and 17:00 – 22:00) previously showed only one window, because the table kept a single entry per weekday. It now lists all of them, and shows Closed only when a day has no open entries, matching `@wabbit/tome-directory`'s open-now rule.
v1.4.1patch

e538c89: Pin, cluster and framing helpers are now importable from `@wabbit/tome-blocks-directory-pack/maps`, as the 1.4.0 changelog described. 1.4.0 shipped them only as internal modules, so a consumer could pass `pinGlyph` and render `DirectoryMapLegend` but could not import the `PinGlyph` type or the builders. Now available from `@wabbit/tome-blocks-directory-pack/maps`: `DEFAULT_PIN_GLYPH`, `PIN_CSS_VARS`, `PIN_WIDTH`, `PIN_HEIGHT`, `pinSvgMarkup`, `pinVisualState`, `pinAriaLabel`, `CLUSTER_CSS_VARS`, `CLUSTER_SIZE`, `clusterPins`, `clusterSvgMarkup`, `clusterAriaLabel`, `boundsForPins`, `framingPinsFor`, and the types `PinGlyph`, `PinGlyphMapViewProps`, `PinOpenState`, `PinStyleInput`, `ClusterablePin`, `ClusterBadgeInput`, `ClusterOptions`, `ClusterResult`, `PinCluster`, `SingleClusterResult`, `BoundablePin`, `BoundsBox`, `FramingOptions`, `LatLng`. All are browser-safe (no map SDK, no server imports). The DOM element builders stay internal to the providers.

  • e538c89: Pin, cluster and framing helpers are now importable from `@wabbit/tome-blocks-directory-pack/maps`, as the 1.4.0 changelog described. 1.4.0 shipped them only as internal modules, so a consumer could pass `pinGlyph` and render `DirectoryMapLegend` but could not import the `PinGlyph` type or the builders. Now available from `@wabbit/tome-blocks-directory-pack/maps`: `DEFAULT_PIN_GLYPH`, `PIN_CSS_VARS`, `PIN_WIDTH`, `PIN_HEIGHT`, `pinSvgMarkup`, `pinVisualState`, `pinAriaLabel`, `CLUSTER_CSS_VARS`, `CLUSTER_SIZE`, `clusterPins`, `clusterSvgMarkup`, `clusterAriaLabel`, `boundsForPins`, `framingPinsFor`, and the types `PinGlyph`, `PinGlyphMapViewProps`, `PinOpenState`, `PinStyleInput`, `ClusterablePin`, `ClusterBadgeInput`, `ClusterOptions`, `ClusterResult`, `PinCluster`, `SingleClusterResult`, `BoundablePin`, `BoundsBox`, `FramingOptions`, `LatLng`. All are browser-safe (no map SDK, no server imports). The DOM element builders stay internal to the providers.
v1.4.0minor

a2fdaa8: Directory map pins redesigned as drop pins: a themed SVG teardrop (house `--tome-directory-pin-*` custom properties, no literal hex) with a tenant-supplied glyph (`pinGlyph` on the map view props, `DirectoryMapLegend` and the `directoryMap` block; default `DEFAULT_PIN_GLYPH`, a neutral dot — brand/vertical marks stay in the consuming site) and a status badge (solid = open, half-filled = closing soon, absent = closed) instead of the old 28px pale disc. Sponsored pins render larger and are never dimmed; tier no longer paints the pin (see `src/maps/shared/pins.ts`'s file header for why). New exports: `PinGlyph`, `PinGlyphMapViewProps`, `DEFAULT_PIN_GLYPH`, `pinSvgMarkup`, `pinVisualState`, `pinAriaLabel`, `PIN_CSS_VARS` from `./maps` subpaths' `shared/pins`, and a new `shared/cluster` module (`clusterPins`, `buildClusterElement`, `clusterSvgMarkup`, `CLUSTER_CSS_VARS`) providing zoom-aware pin clustering with an open-share arc. `../shared/bounds`'s new `framingPinsFor` excludes far outliers (50+ miles) from the map's initial fit-bounds pass without hiding their markers. `DirectoryMapLegend` is now exported from `./render` and draws its swatches from the same SVG builders the map canvases use. Fixes two defects reported against the published build: MapLibre/Mapbox GL JS's `Marker` overwrites the marker root element's `style.opacity` every frame and its `aria-label` to `"Map marker"` inside `addTo()`, so the old closed-state dimming and every pin's accessible label were silently lost — pin visuals now live on an inner element the SDK never touches, and providers re-assert the real `aria-label` after `addTo()`. Fifty-plus pins in a metro no longer overlap into an indistinct blob (clustering) or force the initial view out to cover a store two towns over (`framingPinsFor`). **Consumer-facing SDK/API changes:** - `mount{Maplibre,Mapbox}Map`'s resolved value gained `destroy()` (removes every marker, the zoom-listener, and the map); `markers` stays a live array mutated in place across zoom-driven re-clusters, so existing manual-iteration cleanup code keeps working unchanged. - `mountGoogleMap`'s resolved value also gained `destroy()`; `cleanupMarkers` is now a live array for the same reason. - The `Maplibre`/`Mapbox`/`Google` `*MapLike` test-injection interfaces widened (`off`, `getZoom`, `easeTo` / `getZoom`, `setZoom`, `setCenter`, `addListener`) — only relevant to callers supplying a custom `deps.loadSdk` fake for tests. - `pins.ts`'s old `pinStyle()` export and the tier→color maps (`TIER_BACKGROUND`/`TIER_FOREGROUND`) are removed; `buildPinElement`'s signature is unchanged. Not done in this pass: a "Cluster" legend swatch limitation — clusters vary in size/color by open-share, so the legend shows one representative swatch rather than the full range; and Google's legacy (no `mapId`) `Marker` opacity dimming relies on the native `opacity` marker option rather than baking it into the icon SVG, for parity with how Google renders marker transparency natively.

  • a2fdaa8: Directory map pins redesigned as drop pins: a themed SVG teardrop (house `--tome-directory-pin-*` custom properties, no literal hex) with a tenant-supplied glyph (`pinGlyph` on the map view props, `DirectoryMapLegend` and the `directoryMap` block; default `DEFAULT_PIN_GLYPH`, a neutral dot — brand/vertical marks stay in the consuming site) and a status badge (solid = open, half-filled = closing soon, absent = closed) instead of the old 28px pale disc. Sponsored pins render larger and are never dimmed; tier no longer paints the pin (see `src/maps/shared/pins.ts`'s file header for why). New exports: `PinGlyph`, `PinGlyphMapViewProps`, `DEFAULT_PIN_GLYPH`, `pinSvgMarkup`, `pinVisualState`, `pinAriaLabel`, `PIN_CSS_VARS` from `./maps` subpaths' `shared/pins`, and a new `shared/cluster` module (`clusterPins`, `buildClusterElement`, `clusterSvgMarkup`, `CLUSTER_CSS_VARS`) providing zoom-aware pin clustering with an open-share arc. `../shared/bounds`'s new `framingPinsFor` excludes far outliers (50+ miles) from the map's initial fit-bounds pass without hiding their markers. `DirectoryMapLegend` is now exported from `./render` and draws its swatches from the same SVG builders the map canvases use. Fixes two defects reported against the published build: MapLibre/Mapbox GL JS's `Marker` overwrites the marker root element's `style.opacity` every frame and its `aria-label` to `"Map marker"` inside `addTo()`, so the old closed-state dimming and every pin's accessible label were silently lost — pin visuals now live on an inner element the SDK never touches, and providers re-assert the real `aria-label` after `addTo()`. Fifty-plus pins in a metro no longer overlap into an indistinct blob (clustering) or force the initial view out to cover a store two towns over (`framingPinsFor`). **Consumer-facing SDK/API changes:** - `mount{Maplibre,Mapbox}Map`'s resolved value gained `destroy()` (removes every marker, the zoom-listener, and the map); `markers` stays a live array mutated in place across zoom-driven re-clusters, so existing manual-iteration cleanup code keeps working unchanged. - `mountGoogleMap`'s resolved value also gained `destroy()`; `cleanupMarkers` is now a live array for the same reason. - The `Maplibre`/`Mapbox`/`Google` `*MapLike` test-injection interfaces widened (`off`, `getZoom`, `easeTo` / `getZoom`, `setZoom`, `setCenter`, `addListener`) — only relevant to callers supplying a custom `deps.loadSdk` fake for tests. - `pins.ts`'s old `pinStyle()` export and the tier→color maps (`TIER_BACKGROUND`/`TIER_FOREGROUND`) are removed; `buildPinElement`'s signature is unchanged. Not done in this pass: a "Cluster" legend swatch limitation — clusters vary in size/color by open-share, so the legend shows one representative swatch rather than the full range; and Google's legacy (no `mapId`) `Marker` opacity dimming relies on the native `opacity` marker option rather than baking it into the icon SVG, for parity with how Google renders marker transparency natively.
v1.3.4patch

2651355: `resolveDirectoryMapPins` now prefers a decorated listing's billing-resolved `resolvedTier` over its stored `tier` when building a map pin, so a lapsed subscription stops painting a Premier/Featured pin immediately instead of waiting for the grace-expiry job to downgrade the stored field. Requires `@wabbit/tome-directory`'s new `resolvedTier` field (additive, optional) on decorated listings.

  • 2651355: `resolveDirectoryMapPins` now prefers a decorated listing's billing-resolved `resolvedTier` over its stored `tier` when building a map pin, so a lapsed subscription stops painting a Premier/Featured pin immediately instead of waiting for the grace-expiry job to downgrade the stored field. Requires `@wabbit/tome-directory`'s new `resolvedTier` field (additive, optional) on decorated listings.
v1.3.3
v1.3.2
v1.3.1patch

c8827e7: The promotions rail's end date is now formatted with `formatDisplayDate` in a pinned `en-US` locale and UTC, so the client component no longer mismatches the server markup during hydration. Internal: collection slugs are typed through one package-internal helper instead of inline casts.

  • c8827e7: The promotions rail's end date is now formatted with `formatDisplayDate` in a pinned `en-US` locale and UTC, so the client component no longer mismatches the server markup during hydration. Internal: collection slugs are typed through one package-internal helper instead of inline casts.
  • 252ef2d: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v1.3.0minor

b3e31f6: **BREAKING:** the Google and Mapbox geocoders moved out of the `'use client'` map modules into new `server-only` subpaths, `./maps/google/server` and `./maps/mapbox/server`. Import those once from server boot code to register `googleGeocoder` / `mapboxGeocoder` (the client `./maps/google` / `./maps/mapbox` modules no longer export or register them). The client halves now read only `NEXT_PUBLIC_*` env: the browser key falls back to `NEXT_PUBLIC_DIRECTORY_GOOGLE_MAPS_BROWSER_KEY` and the Mapbox token to `NEXT_PUBLIC_DIRECTORY_MAPBOX_ACCESS_TOKEN` (the old non-public names were read through a dynamic `process.env[name]` that is always undefined in a browser bundle); the server geocoders keep `DIRECTORY_GOOGLE_MAPS_SERVER_KEY` / `DIRECTORY_MAPBOX_ACCESS_TOKEN`. Google `staticMapUrl` now signs with the referrer-restricted browser key instead of the server key, which it had been embedding in every browser-fetched image URL. Call `configureGoogleMapsProvider({ serverApiKey })` from server code only.

  • b3e31f6: **BREAKING:** the Google and Mapbox geocoders moved out of the `'use client'` map modules into new `server-only` subpaths, `./maps/google/server` and `./maps/mapbox/server`. Import those once from server boot code to register `googleGeocoder` / `mapboxGeocoder` (the client `./maps/google` / `./maps/mapbox` modules no longer export or register them). The client halves now read only `NEXT_PUBLIC_*` env: the browser key falls back to `NEXT_PUBLIC_DIRECTORY_GOOGLE_MAPS_BROWSER_KEY` and the Mapbox token to `NEXT_PUBLIC_DIRECTORY_MAPBOX_ACCESS_TOKEN` (the old non-public names were read through a dynamic `process.env[name]` that is always undefined in a browser bundle); the server geocoders keep `DIRECTORY_GOOGLE_MAPS_SERVER_KEY` / `DIRECTORY_MAPBOX_ACCESS_TOKEN`. Google `staticMapUrl` now signs with the referrer-restricted browser key instead of the server key, which it had been embedding in every browser-fetched image URL. Call `configureGoogleMapsProvider({ serverApiKey })` from server code only.
  • 44b39f3: Directory-layer detection now uses blocks-core's `createLayerProbe` (same memoized behavior), so the `@wabbit/tome-blocks-core` peer floor rises to `>=0.18.0`. The unused required `@wabbit/tome-ui` peer is dropped: nothing in this pack imports it, and it was being installed onto every consumer.
  • 56686d6: The listing-detail, listing-grid and map-pins server resolvers still fail soft to their empty state, but now log the swallowed error through `payload.logger.warn` — previously a listing page could silently render nothing with no trace.
v1.2.2patch

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v1.2.1patch

0237caf: The age gate's "Remember me on this device" checkbox is visible again. `@wabbit/tome-ui/base` resets inputs to `appearance: none` with no border or background, so the checkbox rendered as an invisible (but clickable) 16px box. It now restores the native control and tints it with `--tome-color-primary`.

  • 0237caf: The age gate's "Remember me on this device" checkbox is visible again. `@wabbit/tome-ui/base` resets inputs to `appearance: none` with no border or background, so the checkbox rendered as an invisible (but clickable) 16px box. It now restores the native control and tints it with `--tome-color-primary`.
v1.2.0minor

6d54319: Add client-side event tracking for the directory layer's owner analytics amendment. New public API at `@wabbit/tome-blocks-directory-pack/track` (also re-exported from the root barrel): `trackDirectoryEvent()`, a batched-`sendBeacon` client queue, and `<DirectoryEventBeacon>`/`<DirectoryClickTrack>`, the two composable wrappers that emit `view`, `sponsoredImpression`, `sponsoredClick`, `website`, `phone`, and `directions` events. Both no-op entirely when the browser sends Global Privacy Control or Do Not Track. Wired into the pack's existing sponsored-card surfaces: `directoryListingGrid` (impression + click on every card carrying the Sponsored label, tagged by a new `sponsoredSurface` prop so one block instance can serve the home band, a city page, or a plain listing-top grid), `directoryListingDetail` (a de-duped page view + a website-click tap), and `directoryMap` (a sponsored-pin click reaching both the canvas map providers and the accessible fallback list, plus impression tracking on the fallback list). No new CSS; no change to any existing block's authored-field contract beyond the additive `sponsoredSurface`/`viewSource` props.

  • 6d54319: Add client-side event tracking for the directory layer's owner analytics amendment. New public API at `@wabbit/tome-blocks-directory-pack/track` (also re-exported from the root barrel): `trackDirectoryEvent()`, a batched-`sendBeacon` client queue, and `<DirectoryEventBeacon>`/`<DirectoryClickTrack>`, the two composable wrappers that emit `view`, `sponsoredImpression`, `sponsoredClick`, `website`, `phone`, and `directions` events. Both no-op entirely when the browser sends Global Privacy Control or Do Not Track. Wired into the pack's existing sponsored-card surfaces: `directoryListingGrid` (impression + click on every card carrying the Sponsored label, tagged by a new `sponsoredSurface` prop so one block instance can serve the home band, a city page, or a plain listing-top grid), `directoryListingDetail` (a de-duped page view + a website-click tap), and `directoryMap` (a sponsored-pin click reaching both the canvas map providers and the accessible fallback list, plus impression tracking on the fallback list). No new CSS; no change to any existing block's authored-field contract beyond the additive `sponsoredSurface`/`viewSource` props.
v1.1.5
v1.1.4
v1.1.3
v1.1.2patch

e40f6c6: Close the second registry read in `DirectoryListingGrid`: card links. 1.1.1 made the Type facet independent of the process-wide listing-type registry, but each card's detail link was still built from `getListingType(listing.listingType)?.detailRoute`, so a host with the registry populated on the server only still got SSR cards wrapped in `<a>` and client cards without one (React hydration error #418 persisted on tome-starter's `/blocks/directory-pack`). The block gains an optional `detailRoutes` field (`{ [listingTypeValue]: '/route/[slug]' }`) used verbatim when set, with the registry as the fallback when absent; `./demo` passes it. `test/integration/demo-determinism.test.ts` now renders EVERY block and variant's demo through `renderToStaticMarkup` with an empty registry and with a host-populated one (the demo's own `trades-shop` under a different detail route, plus an unrelated type) and asserts identical markup, so a future registry read anywhere in the pack fails the suite rather than surfacing as a consumer hydration error. Controls prove the registry does change the grid's links and facets when the explicit fields are omitted. No breaking changes: `detailRoutes` is optional and the registry fallback is unchanged.

  • e40f6c6: Close the second registry read in `DirectoryListingGrid`: card links. 1.1.1 made the Type facet independent of the process-wide listing-type registry, but each card's detail link was still built from `getListingType(listing.listingType)?.detailRoute`, so a host with the registry populated on the server only still got SSR cards wrapped in `<a>` and client cards without one (React hydration error #418 persisted on tome-starter's `/blocks/directory-pack`). The block gains an optional `detailRoutes` field (`{ [listingTypeValue]: '/route/[slug]' }`) used verbatim when set, with the registry as the fallback when absent; `./demo` passes it. `test/integration/demo-determinism.test.ts` now renders EVERY block and variant's demo through `renderToStaticMarkup` with an empty registry and with a host-populated one (the demo's own `trades-shop` under a different detail route, plus an unrelated type) and asserts identical markup, so a future registry read anywhere in the pack fails the suite rather than surfacing as a consumer hydration error. Controls prove the registry does change the grid's links and facets when the explicit fields are omitted. No breaking changes: `detailRoutes` is optional and the registry fallback is unchanged.
v1.1.1patch

67f96d3: Make the `./demo` gallery previews render identically on server and client, and stop emitting a media document id as an image URL. - `DirectoryListingGrid` gains an optional `facetListingTypes` block field. When set it is used verbatim for the Type facet; when absent the renderer falls back to the process-wide listing-type registry as before. A host that registers its listing types in its server config only (every consumer) had the registry populated during SSR and empty in the browser, so a gallery preview inside a client island rendered the facet nav on the server and dropped it on the client — React hydration error #418 on tome-starter's `/blocks/directory-pack`. `getDirectoryListingGridDemoProps` now passes an explicit `facetListingTypes`. - The listing-grid, listing-detail, and mega-menu demos no longer place `DemoContext.placeholderMediaId` (a media DOCUMENT id) into `photos` / `image.url`, which the renderers use directly as `<img src>` — the gallery host requested `/blocks/demo-placeholder` and 404'd on every card. Listing photos are now empty (the pack's own fallback frame renders) and the mega-menu demo cards use the `muted` style. - New `test/integration/demo-determinism.test.ts` proves both: a real `renderToStaticMarkup` of the grid demo is byte-identical with an empty registry and with a registry holding an unrelated type (with a control showing the registry DOES change the markup when `facetListingTypes` is omitted), and no demo prop across all eight blocks and every variant carries the media id in a URL slot. No breaking changes: `facetListingTypes` is optional and the registry fallback is unchanged.

  • 67f96d3: Make the `./demo` gallery previews render identically on server and client, and stop emitting a media document id as an image URL. - `DirectoryListingGrid` gains an optional `facetListingTypes` block field. When set it is used verbatim for the Type facet; when absent the renderer falls back to the process-wide listing-type registry as before. A host that registers its listing types in its server config only (every consumer) had the registry populated during SSR and empty in the browser, so a gallery preview inside a client island rendered the facet nav on the server and dropped it on the client — React hydration error #418 on tome-starter's `/blocks/directory-pack`. `getDirectoryListingGridDemoProps` now passes an explicit `facetListingTypes`. - The listing-grid, listing-detail, and mega-menu demos no longer place `DemoContext.placeholderMediaId` (a media DOCUMENT id) into `photos` / `image.url`, which the renderers use directly as `<img src>` — the gallery host requested `/blocks/demo-placeholder` and 404'd on every card. Listing photos are now empty (the pack's own fallback frame renders) and the mega-menu demo cards use the `muted` style. - New `test/integration/demo-determinism.test.ts` proves both: a real `renderToStaticMarkup` of the grid demo is byte-identical with an empty registry and with a registry holding an unrelated type (with a control showing the registry DOES change the markup when `facetListingTypes` is omitted), and no demo prop across all eight blocks and every variant carries the media id in a URL slot. No breaking changes: `facetListingTypes` is optional and the registry fallback is unchanged.
v1.1.0minor

d97cd99: Add `./meta` and `./demo` export subpaths so a `@wabbit/tome-blocks-gallery` consumer (e.g. tome-starter's `/blocks` route) can register this pack's eight blocks — mirrors `@wabbit/tome-blocks-cinema-pack` and `@wabbit/tome-blocks-dossier-pack` exactly. - `./meta` exports `directoryPackBlockMeta`: every block's client-safe `meta` plus its declared `variants`, in the pack's registration order — no Payload import, no CSS Module. - `./demo` exports `getDemoProps(blockSlug, variant, ctx?)`: realistic, brand-free fictional props (a generic "Demo City" trades directory — plumbing/electrical/HVAC) for all eight blocks across every declared variant. Returns `null` for a slug this pack doesn't own. - Both subpaths are proven client-safe two ways: a static source-import walk (`test/integration/meta-demo-subpath.test.ts`) and a real load of the built files under a bare `node -e` child process with none of this package's own test-harness CSS/`server-only` stubs — so a regression that reintroduces a Payload or CSS dependency would actually fail, not pass silently. No breaking changes — purely additive exports; every existing subpath (`.`, `./render`, `./render/register`, `./server`, `./maps*`, `./chrome`, `./styles.css`) is unchanged.

  • d97cd99: Add `./meta` and `./demo` export subpaths so a `@wabbit/tome-blocks-gallery` consumer (e.g. tome-starter's `/blocks` route) can register this pack's eight blocks — mirrors `@wabbit/tome-blocks-cinema-pack` and `@wabbit/tome-blocks-dossier-pack` exactly. - `./meta` exports `directoryPackBlockMeta`: every block's client-safe `meta` plus its declared `variants`, in the pack's registration order — no Payload import, no CSS Module. - `./demo` exports `getDemoProps(blockSlug, variant, ctx?)`: realistic, brand-free fictional props (a generic "Demo City" trades directory — plumbing/electrical/HVAC) for all eight blocks across every declared variant. Returns `null` for a slug this pack doesn't own. - Both subpaths are proven client-safe two ways: a static source-import walk (`test/integration/meta-demo-subpath.test.ts`) and a real load of the built files under a bare `node -e` child process with none of this package's own test-harness CSS/`server-only` stubs — so a regression that reintroduces a Payload or CSS dependency would actually fail, not pass silently. No breaking changes — purely additive exports; every existing subpath (`.`, `./render`, `./render/register`, `./server`, `./maps*`, `./chrome`, `./styles.css`) is unchanged.
v1.0.0major

0636540: Fix three defects in `DirectoryAgeGate` (paired with the `@wabbit/tome-directory` age-gate fix in this same change): 1. **The alternate-class button was a duplicate of Enter.** It called the same `submitDob()` with no signal that the alternate class was being claimed, so pressing either button sent an identical request. The alternate button now asserts `claimAlternateClass: true`; plain Enter asserts `false`. Fixes the visitor-facing half of the compliance defect where an under-`minAge` visitor could be admitted as the alternate class without ever choosing to claim it. 2. **No terminal state on the success path — the reported stall.** `submitDob()` set status to `submitting` and never left it on success, so a slow destination route (or a hung/failed request) left the button on "Checking…", disabled, forever, with no way to retry. The verify request is now bounded by an `AbortController` (`verifyTimeoutMs` prop, default 10s); a passing verify moves to a distinct `success` state ("Entering…", still disabled since the consumer is expected to navigate away via `onVerified`) instead of staying on `submitting`; a timed-out, aborted, network-failed, or non-2xx response lands in a new `error` state (readable message, button re-enabled) that is now distinct from `denied` (visitor doesn't meet the age requirement) — previously both cases were indistinguishable and both read as "you're not old enough," which was actively misleading for a transport failure. 3. **"Remember me on this device" never left the component.** The checkbox state is now included in the request (`remember`) alongside `claimAlternateClass`. **Breaking changes** (hence major, not patch): - `DirectoryAgeGateProps['verifyAction']` changed from `(dob: DirectoryAgeGateDob) => Promise<DirectoryAgeGateVerifyResult>` to `(request: DirectoryAgeGateVerifyRequest) => Promise<DirectoryAgeGateVerifyResult>`, where `DirectoryAgeGateVerifyRequest = { dob, claimAlternateClass?, remember? }`. Any consumer supplying a custom `verifyAction` must read `request.dob` instead of `dob`, and should honor `request.claimAlternateClass`/`request.remember` the way `defaultAgeGateVerify` does, or its gate will regress on defects 1 and 3 above. **The reference consumer is not affected** — it uses the default `verifyEndpoint` fetch path, not a custom `verifyAction` (verified against its `AgeGateClient.tsx`). - `DirectoryAgeGateVerifyResult` gained an optional `error?: 'timeout' | 'network'` field. Additive — a consumer reading only `.ok`/`.class` is unaffected. The real verify endpoint never sets this field; it's set only by client-side transport helpers. - New `verifyTimeoutMs` prop (optional, default 10000) — additive, no action needed. - `defaultAgeGateVerify`'s exported signature changed (now takes a `DirectoryAgeGateVerifyRequest` object plus an `options: { signal? }` argument instead of a bare `dob`) — this helper is internal to the package (not re-exported from any `package.json` `exports` subpath), so this is not a public API break, noted here only for completeness. Tokens-only CSS: added `.errorNotice` to `DirectoryAgeGate.module.css` using `var(--tome-color-warning-text, var(--tome-color-error-text))` — no literal colors.

  • 0636540: Fix three defects in `DirectoryAgeGate` (paired with the `@wabbit/tome-directory` age-gate fix in this same change): 1. **The alternate-class button was a duplicate of Enter.** It called the same `submitDob()` with no signal that the alternate class was being claimed, so pressing either button sent an identical request. The alternate button now asserts `claimAlternateClass: true`; plain Enter asserts `false`. Fixes the visitor-facing half of the compliance defect where an under-`minAge` visitor could be admitted as the alternate class without ever choosing to claim it. 2. **No terminal state on the success path — the reported stall.** `submitDob()` set status to `submitting` and never left it on success, so a slow destination route (or a hung/failed request) left the button on "Checking…", disabled, forever, with no way to retry. The verify request is now bounded by an `AbortController` (`verifyTimeoutMs` prop, default 10s); a passing verify moves to a distinct `success` state ("Entering…", still disabled since the consumer is expected to navigate away via `onVerified`) instead of staying on `submitting`; a timed-out, aborted, network-failed, or non-2xx response lands in a new `error` state (readable message, button re-enabled) that is now distinct from `denied` (visitor doesn't meet the age requirement) — previously both cases were indistinguishable and both read as "you're not old enough," which was actively misleading for a transport failure. 3. **"Remember me on this device" never left the component.** The checkbox state is now included in the request (`remember`) alongside `claimAlternateClass`. **Breaking changes** (hence major, not patch): - `DirectoryAgeGateProps['verifyAction']` changed from `(dob: DirectoryAgeGateDob) => Promise<DirectoryAgeGateVerifyResult>` to `(request: DirectoryAgeGateVerifyRequest) => Promise<DirectoryAgeGateVerifyResult>`, where `DirectoryAgeGateVerifyRequest = { dob, claimAlternateClass?, remember? }`. Any consumer supplying a custom `verifyAction` must read `request.dob` instead of `dob`, and should honor `request.claimAlternateClass`/`request.remember` the way `defaultAgeGateVerify` does, or its gate will regress on defects 1 and 3 above. **The reference consumer is not affected** — it uses the default `verifyEndpoint` fetch path, not a custom `verifyAction` (verified against its `AgeGateClient.tsx`). - `DirectoryAgeGateVerifyResult` gained an optional `error?: 'timeout' | 'network'` field. Additive — a consumer reading only `.ok`/`.class` is unaffected. The real verify endpoint never sets this field; it's set only by client-side transport helpers. - New `verifyTimeoutMs` prop (optional, default 10000) — additive, no action needed. - `defaultAgeGateVerify`'s exported signature changed (now takes a `DirectoryAgeGateVerifyRequest` object plus an `options: { signal? }` argument instead of a bare `dob`) — this helper is internal to the package (not re-exported from any `package.json` `exports` subpath), so this is not a public API break, noted here only for completeness. Tokens-only CSS: added `.errorNotice` to `DirectoryAgeGate.module.css` using `var(--tome-color-warning-text, var(--tome-color-error-text))` — no literal colors.
  • 7050ab3: Add an `unpaid` entry to `maps/shared/pins.ts`'s `TIER_BACKGROUND`/`TIER_FOREGROUND` `Record<DirectoryTier, string>` maps, matching `@wabbit/tome-directory`'s new `unpaid` floor tier (see that package's changeset for the full sellable-entry-tier design). Same muted styling `claimed` already had — a pin's fill color is a visual rank cue, not a claim-status cue, so the new floor tier renders identically to how `claimed` rendered before. Purely additive; no behavior change for any existing tier.
  • Updated dependencies [0636540]
  • Updated dependencies [f248c05]
  • Updated dependencies [e032576]
  • Updated dependencies [7050ab3] - @wabbit/tome-directory@1.0.0
v0.1.2patch

39c34f3: Fix three consumer-confirmed defects in the directory map/registration path (a production consumer, Next 16): 1. `DirectoryMap.tsx` never forwarded `gatePassed` on to `provider.MapView` — every provider's `MapView` independently fail-secures on a missing `gatePassed` (`assertGatePassed`), so even a genuinely-passed age gate never mounted a map. `DirectoryMap` now forwards `block.gatePassed ?? true` (matching its own already-resolved "undefined = no gate configured = treat as passed" semantics) to the provider. 2. Added an optional `onPinHover?: (id: string | null) => void` extension (`../maps/shared/hover.ts`, composed the same local way `GateGuardedMapViewProps` widens the shared `DirectoryMapViewProps` contract) and wired it into all three providers' marker creation — `mouseenter`/`mouseleave` on the DOM marker element for maplibre/mapbox and the AdvancedMarkerElement `content` element for google, `mouseover`/`mouseout` for google's legacy `Marker` fallback. Guarded/no-op wherever the caller doesn't supply it. 3. Investigated the reported "Invalid hook call... useId" crash rendering `directoryReviewForm`/`directoryClaimCta` through `@wabbit/tome-blocks-directory-pack/server`. Verified against a real build: `tsup`'s `bundle: false` correctly preserves each file's `'use client'` directive and `dist/server/index.js` genuinely imports (never inlines) the two static blocks from `../render/register.js` — the specific mechanism the report suspected does not reproduce against current source. Added a regression test (`test/integration/rsc-boundary.test.ts`) asserting both invariants directly against built `dist/` output, so a future build-pipeline regression (e.g. `bundle: false` flipped, or `wrapHydrated` mistakenly applied to a static block) fails loudly. Flagging for a separate platform-level look: the render registry's `globalThis`-anchored sharing across Next's separate react-server/SSR/client module-graph instances (`@wabbit/tome-blocks-core/render/registry.ts`) is a plausible root cause for the consumer's actual crash and is out of scope for a single-pack patch. Also unlocked direct `node --test` coverage of this pack's render components for the first time: `test/support/stub-css-modules.mjs` (a Node ESM loader hook, mirroring the existing `stub-server-only.mjs` pattern) stubs `.css`/`.module.css` imports so `DirectoryMap.tsx` can be imported and exercised directly under the plain test runner.

  • 39c34f3: Fix three consumer-confirmed defects in the directory map/registration path (a production consumer, Next 16): 1. `DirectoryMap.tsx` never forwarded `gatePassed` on to `provider.MapView` — every provider's `MapView` independently fail-secures on a missing `gatePassed` (`assertGatePassed`), so even a genuinely-passed age gate never mounted a map. `DirectoryMap` now forwards `block.gatePassed ?? true` (matching its own already-resolved "undefined = no gate configured = treat as passed" semantics) to the provider. 2. Added an optional `onPinHover?: (id: string | null) => void` extension (`../maps/shared/hover.ts`, composed the same local way `GateGuardedMapViewProps` widens the shared `DirectoryMapViewProps` contract) and wired it into all three providers' marker creation — `mouseenter`/`mouseleave` on the DOM marker element for maplibre/mapbox and the AdvancedMarkerElement `content` element for google, `mouseover`/`mouseout` for google's legacy `Marker` fallback. Guarded/no-op wherever the caller doesn't supply it. 3. Investigated the reported "Invalid hook call... useId" crash rendering `directoryReviewForm`/`directoryClaimCta` through `@wabbit/tome-blocks-directory-pack/server`. Verified against a real build: `tsup`'s `bundle: false` correctly preserves each file's `'use client'` directive and `dist/server/index.js` genuinely imports (never inlines) the two static blocks from `../render/register.js` — the specific mechanism the report suspected does not reproduce against current source. Added a regression test (`test/integration/rsc-boundary.test.ts`) asserting both invariants directly against built `dist/` output, so a future build-pipeline regression (e.g. `bundle: false` flipped, or `wrapHydrated` mistakenly applied to a static block) fails loudly. Flagging for a separate platform-level look: the render registry's `globalThis`-anchored sharing across Next's separate react-server/SSR/client module-graph instances (`@wabbit/tome-blocks-core/render/registry.ts`) is a plausible root cause for the consumer's actual crash and is out of scope for a single-pack patch. Also unlocked direct `node --test` coverage of this pack's render components for the first time: `test/support/stub-css-modules.mjs` (a Node ESM loader hook, mirroring the existing `stub-server-only.mjs` pattern) stubs `.css`/`.module.css` imports so `DirectoryMap.tsx` can be imported and exercised directly under the plain test runner.
v0.1.1patch

8ae9519: Fix RSC boundary violation: `./render/register`'s `registerDirectoryRenderers` transitively imported `./server/createHydratedRenderers` (which carries `import 'server-only'`), so any Client Component importing `./render/register` for the plain `renderers` map broke its build under Next 16 Turbopack ("'server-only' cannot be imported from a Client Component module"). `registerDirectoryRenderers` moved to `@wabbit/tome-blocks-directory-pack/server` — composing server-only hydration resolvers onto render components is a server-only concern, and `./render/register` now exports only the client-safe `renderers` map / `registerRenderers()`, with no edge to `../server/*`. Consumers must update their import: `registerDirectoryRenderers` now comes from `@wabbit/tome-blocks-directory-pack/server`, not `@wabbit/tome-blocks-directory-pack/render/register`. `scripts/assert-rsc-boundaries.mjs` (repo root) is extended to statically check every package's `src/render/{register,index}.ts` entry point for `server-only` reachability, so this class of regression fails before reaching a consumer build.

  • 8ae9519: Fix RSC boundary violation: `./render/register`'s `registerDirectoryRenderers` transitively imported `./server/createHydratedRenderers` (which carries `import 'server-only'`), so any Client Component importing `./render/register` for the plain `renderers` map broke its build under Next 16 Turbopack ("'server-only' cannot be imported from a Client Component module"). `registerDirectoryRenderers` moved to `@wabbit/tome-blocks-directory-pack/server` — composing server-only hydration resolvers onto render components is a server-only concern, and `./render/register` now exports only the client-safe `renderers` map / `registerRenderers()`, with no edge to `../server/*`. Consumers must update their import: `registerDirectoryRenderers` now comes from `@wabbit/tome-blocks-directory-pack/server`, not `@wabbit/tome-blocks-directory-pack/render/register`. `scripts/assert-rsc-boundaries.mjs` (repo root) is extended to statically check every package's `src/render/{register,index}.ts` entry point for `server-only` reachability, so this class of regression fails before reaching a consumer build.
v0.1.0minor

d606673: New package. Pro-tier local-directory block pack — `directoryMap`, `directoryListingGrid`, `directoryListingDetail`, `directoryPromotionsRail`, `directoryClaimCta`, `directoryReviewForm`, `directoryAgeGate`, `directoryMegaMenu` — plus the map/geocoder provider registry (`@wabbit/tome-blocks-directory-pack/maps`, default MapLibre + Nominatim) with typed STUB implementations for MapLibre, Google, and Mapbox providers. All eight blocks ship with final field schemas and variant keys; render bodies, hydration resolvers (`./server`), and the three map provider bodies are STUBs replaced in place by Wave 2's W2-BLOCKS-A/B and W2-MAPS agents without a breaking change to this package's public barrels.

  • d606673: New package. Pro-tier local-directory block pack — `directoryMap`, `directoryListingGrid`, `directoryListingDetail`, `directoryPromotionsRail`, `directoryClaimCta`, `directoryReviewForm`, `directoryAgeGate`, `directoryMegaMenu` — plus the map/geocoder provider registry (`@wabbit/tome-blocks-directory-pack/maps`, default MapLibre + Nominatim) with typed STUB implementations for MapLibre, Google, and Mapbox providers. All eight blocks ship with final field schemas and variant keys; render bodies, hydration resolvers (`./server`), and the three map provider bodies are STUBs replaced in place by Wave 2's W2-BLOCKS-A/B and W2-MAPS agents without a breaking change to this package's public barrels.

Blocks Proposal Pack

v0.6.4
v0.6.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.6.3patch

9994d34: proposal-plans: the "This proposal" tag moves under the plan name when they don't fit on one line. The name used to shrink instead, so a short name like "Tend" broke mid-word at narrower desktop widths.

  • 9994d34: proposal-plans: the "This proposal" tag moves under the plan name when they don't fit on one line. The name used to shrink instead, so a short name like "Tend" broke mid-word at narrower desktop widths.
v0.6.2patch

1f6bba5: proposal-asks: the per-row lead-in is now optional. This matches proposal-checklist. A row can be plain text and the numeral still marks it, so copy written to a no-bold-lead-in voice rule never has to fake a lead-in. Existing rows render unchanged.

  • 1f6bba5: proposal-asks: the per-row lead-in is now optional. This matches proposal-checklist. A row can be plain text and the numeral still marks it, so copy written to a no-bold-lead-in voice rule never has to fake a lead-in. Existing rows render unchanged.
v0.6.1patch

f820792: The plans block's totals strip now follows the client's add-on choice on the same page, so its "today" and "at go-live" figures match the add-ons strip. The add-ons change event (`tome:proposal-addons-change`) now carries the new figures (`totalCents`, `todayCents`, `goLiveCents`) alongside `keys`. While a proposal is open, the plans strip listens for it. Existing listeners that read only `keys` are unaffected.

  • f820792: The plans block's totals strip now follows the client's add-on choice on the same page, so its "today" and "at go-live" figures match the add-ons strip. The add-ons change event (`tome:proposal-addons-change`) now carries the new figures (`totalCents`, `todayCents`, `goLiveCents`) alongside `keys`. While a proposal is open, the plans strip listens for it. Existing listeners that read only `keys` are unaffected.
v0.6.0minor

15d7848: New `proposal-addons` block: optional priced extras a client can tick before signing, with a live strip showing the new total, today's payment and the go-live balance. The add-ons come from the deal through the render context (`ctx.addOns`, `ctx.baseCents`, `ctx.depositFraction`), never from the block, so the host prices exactly what the client saw. While a proposal is sent, each add-on is a checkbox named `tome-proposal-addon` inside a `data-tome-proposal-addons="choice"` wrapper, and every change dispatches `tome:proposal-addons-change`. Before signing the block shows the defaults; after signing, the chosen set. Offer-card prices and cover figures gain a `base` binding (and a `{base}` token): the price before add-ons, so the card for the core work keeps its own price once an extra is added. `total` still means what the client pays. Nothing changes for existing proposals: the new context fields are optional, and a context without them renders exactly as before. No consumer action is needed unless you want to offer add-ons.

  • 15d7848: New `proposal-addons` block: optional priced extras a client can tick before signing, with a live strip showing the new total, today's payment and the go-live balance. The add-ons come from the deal through the render context (`ctx.addOns`, `ctx.baseCents`, `ctx.depositFraction`), never from the block, so the host prices exactly what the client saw. While a proposal is sent, each add-on is a checkbox named `tome-proposal-addon` inside a `data-tome-proposal-addons="choice"` wrapper, and every change dispatches `tome:proposal-addons-change`. Before signing the block shows the defaults; after signing, the chosen set. Offer-card prices and cover figures gain a `base` binding (and a `{base}` token): the price before add-ons, so the card for the core work keeps its own price once an extra is added. `total` still means what the client pays. Nothing changes for existing proposals: the new context fields are optional, and a context without them renders exactly as before. No consumer action is needed unless you want to offer add-ons.
v0.5.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.5.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.5.0minor

072f973: New optional fields and theme hooks: an inline call link on the split hero, image labels, a quotes-grid testimonial layout, an open-first FAQ, and a stat-strip note. **marketing-starter, `high-impact-hero`:** the `split` variant now shows `callAction` (`label`, `phone`) and renders it as an inline `tel:` link after the buttons, for example "or call (555) 010-2030" (`data-hero-call`). An empty label reads "or call" after buttons and "Call" without them. In every variant that shows supporting images, `supportingImages[]` gains `label`, a short tag set over the image such as "Illustrative photo" (`data-compliance-label`), and `captionMeta`, a smaller second caption line (`data-caption-meta`). Every variant that renders actions marks its action row `data-hero-actions` (quick-ask already did): the row holding the buttons and the inline call on `split`, otherwise the button row itself. The attribute is the only markup change. **marketing-starter, `testimonial`:** each entry gains an optional `complianceLabel`, shown with its quote in every layout (`data-compliance-label`). The `layout` select adds `quotes-grid`: every testimonial becomes a short quote in a responsive grid, with an optional `stat` tile (`value`, `text`, `label`, `position`) placed at its 1-based cell among the quotes, default 2. Hooks: `data-testimonial-layout="quotes-grid"`, `data-testimonial-cell` on every cell, `data-testimonial-stat` on the tile, `data-testimonial-stat-value` on its value. **marketing-starter, `faq`:** an `openFirst` checkbox (default off) renders the first question open and marks the root with `data-faq-open-first`. Every FAQ now carries part hooks: `data-faq-item` on each `<details>`, `data-faq-question` on its `<summary>`, and `data-faq-answer` on a new plain `<div>` around each answer (the answer's own element comes from your rich-text adapter, which accepts only a class name). **proposal-pack, `proposal-stat-strip`:** an optional `note` renders a source or compliance line under the figures (`data-stat-note`), with the same tokens as the stats. When the section header does not show, the root now carries `data-stat-strip-headless`; nothing renders in the header's place, so no gap is left above the figures. Every new field is optional, and the rendered markup is unchanged while they are empty; the additions to existing output are the FAQ part-hook attributes, the unstyled answer wrapper, the `data-hero-actions` attribute on hero action rows, and the `data-stat-strip-headless` attribute on stat strips that already had no header. The new fields add columns, so run your Payload migration and regenerate types. **extras:** the shared `HeroLinkList` accepts an optional `containerData` (extra `data-*` attributes for its row element). Omitted, the row renders exactly as before.

  • 072f973: New optional fields and theme hooks: an inline call link on the split hero, image labels, a quotes-grid testimonial layout, an open-first FAQ, and a stat-strip note. **marketing-starter, `high-impact-hero`:** the `split` variant now shows `callAction` (`label`, `phone`) and renders it as an inline `tel:` link after the buttons, for example "or call (555) 010-2030" (`data-hero-call`). An empty label reads "or call" after buttons and "Call" without them. In every variant that shows supporting images, `supportingImages[]` gains `label`, a short tag set over the image such as "Illustrative photo" (`data-compliance-label`), and `captionMeta`, a smaller second caption line (`data-caption-meta`). Every variant that renders actions marks its action row `data-hero-actions` (quick-ask already did): the row holding the buttons and the inline call on `split`, otherwise the button row itself. The attribute is the only markup change. **marketing-starter, `testimonial`:** each entry gains an optional `complianceLabel`, shown with its quote in every layout (`data-compliance-label`). The `layout` select adds `quotes-grid`: every testimonial becomes a short quote in a responsive grid, with an optional `stat` tile (`value`, `text`, `label`, `position`) placed at its 1-based cell among the quotes, default 2. Hooks: `data-testimonial-layout="quotes-grid"`, `data-testimonial-cell` on every cell, `data-testimonial-stat` on the tile, `data-testimonial-stat-value` on its value. **marketing-starter, `faq`:** an `openFirst` checkbox (default off) renders the first question open and marks the root with `data-faq-open-first`. Every FAQ now carries part hooks: `data-faq-item` on each `<details>`, `data-faq-question` on its `<summary>`, and `data-faq-answer` on a new plain `<div>` around each answer (the answer's own element comes from your rich-text adapter, which accepts only a class name). **proposal-pack, `proposal-stat-strip`:** an optional `note` renders a source or compliance line under the figures (`data-stat-note`), with the same tokens as the stats. When the section header does not show, the root now carries `data-stat-strip-headless`; nothing renders in the header's place, so no gap is left above the figures. Every new field is optional, and the rendered markup is unchanged while they are empty; the additions to existing output are the FAQ part-hook attributes, the unstyled answer wrapper, the `data-hero-actions` attribute on hero action rows, and the `data-stat-strip-headless` attribute on stat strips that already had no header. The new fields add columns, so run your Payload migration and regenerate types. **extras:** the shared `HeroLinkList` accepts an optional `containerData` (extra `data-*` attributes for its row element). Omitted, the row renders exactly as before.
v0.4.0minor

ee3a168: `proposal-stat-strip` stats take an optional `label` (e.g. "Licensed"), rendered above the value with a `[data-stat-label]` hook. The field is optional and renders nothing when empty, so existing strips are unchanged. The label accepts the same deal tokens as the value and caption.

  • ee3a168: `proposal-stat-strip` stats take an optional `label` (e.g. "Licensed"), rendered above the value with a `[data-stat-label]` hook. The field is optional and renders nothing when empty, so existing strips are unchanged. The label accepts the same deal tokens as the value and caption.
v0.3.6patch

991cb97: `proposal-stat-strip` exposes stable `data-*` part hooks for themes: `data-stat-list`, `data-stat`, `data-stat-value`, `data-stat-caption` and `data-stat-header`. The hooks are pure additions: classes, styles and layout are unchanged. `data-stat-header` is a `display: contents` wrapper around the section header strip, rendered only when the strip shows, so it adds no box; target the strip's text through it (for example `[data-stat-header] p`).

  • 991cb97: `proposal-stat-strip` exposes stable `data-*` part hooks for themes: `data-stat-list`, `data-stat`, `data-stat-value`, `data-stat-caption` and `data-stat-header`. The hooks are pure additions: classes, styles and layout are unchanged. `data-stat-header` is a `display: contents` wrapper around the section header strip, rendered only when the strip shows, so it adds no box; target the strip's text through it (for example `[data-stat-header] p`).
v0.3.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.3.4patch

f18bfd0: The Proposal Media Spread call-to-action link now uses the `rel` a registered CTA resolver supplies. With no resolver registered the markup is unchanged: a new-tab link still gets `rel="noopener noreferrer"`. Needs `@wabbit/tome-blocks-house` with CTA resolver registration to take effect.

  • f18bfd0: The Proposal Media Spread call-to-action link now uses the `rel` a registered CTA resolver supplies. With no resolver registered the markup is unchanged: a new-tab link still gets `rel="noopener noreferrer"`. Needs `@wabbit/tome-blocks-house` with CTA resolver registration to take effect.
v0.3.3patch

483e0a1: The plans block's preview and admin hints now use the generic Essential, Plus and Premier service levels. The preview prices are illustrative, and the plan keys in the preview data are renamed to match. The selectedBind field and its stored values are unchanged.

  • 483e0a1: The plans block's preview and admin hints now use the generic Essential, Plus and Premier service levels. The preview prices are illustrative, and the plan keys in the preview data are renamed to match. The selectedBind field and its stored values are unchanged.
v0.3.2patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
v0.3.1patch

8c84e70: Solid CTAs and tags filled with the accent text ink now use the page color as their label instead of `on-accent`, which pairs with the accent fill and measured about 2:1 on the text ink. campaign-hero, campaign-close and campaign-tiers also read the band's accent ink and its label on solid-dark bands.

  • 8c84e70: Solid CTAs and tags filled with the accent text ink now use the page color as their label instead of `on-accent`, which pairs with the accent fill and measured about 2:1 on the text ink. campaign-hero, campaign-close and campaign-tiers also read the band's accent ink and its label on solid-dark bands.
v0.3.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 14 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - The shared `styles/proposalGrid.module.css` (the grid every proposal block composes from) is now `styles/proposalGrid.tome-css`; each block renders it alongside its own stylesheet, and React dedupes it to one copy per page. The global `styles/proposal-grid.css` that sites import is unchanged. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 14 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - The shared `styles/proposalGrid.module.css` (the grid every proposal block composes from) is now `styles/proposalGrid.tome-css`; each block renders it alongside its own stylesheet, and React dedupes it to one copy per page. The global `styles/proposal-grid.css` that sites import is unchanged. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.2.3patch

40be7f8: CSS files are copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook, whose output the type-declaration phase can remove.

  • 40be7f8: CSS files are copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook, whose output the type-declaration phase can remove.
v0.2.2patch

proposal-stat-strip and proposal-monthly size their columns to the item count (2–4) instead of a fixed three. A two-stat strip or two-stage path no longer leaves an empty third column and sits off-center, and a four-item one no longer wraps.

  • proposal-stat-strip and proposal-monthly size their columns to the item count (2–4) instead of a fixed three. A two-stat strip or two-stage path no longer leaves an empty third column and sits off-center, and a four-item one no longer wraps.
  • Signature field hints no longer carry a personal name.
v0.2.1patch

proposal-plans: the plan columns and totals now span the content track (subgrid root, like proposal-monthly) instead of the 600px reading measure, where three cards were ~200px each and broke words mid-word on desktop. The note stays on the measure.

  • proposal-plans: the plan columns and totals now span the content track (subgrid root, like proposal-monthly) instead of the 600px reading measure, where three cards were ~200px each and broke words mid-word on desktop. The note stays on the measure.
v0.2.0minor

05b8575: New block: `proposal-plans`, the "choose your service level" picker — one to four priced plan columns (e.g. Care/Tend/Grow) with the level a proposal is set to highlighted, an optional client radio choice (mounted only while the deal is `sent`), and a running-total strip (today / at go-live / then monthly). Adds two new optional `ProposalRenderContext` fields — `careTier` and `depositAllowed` (defaults to `true`) — read by `readProposalContext`; both are additive and backward compatible.

  • 05b8575: New block: `proposal-plans`, the "choose your service level" picker — one to four priced plan columns (e.g. Care/Tend/Grow) with the level a proposal is set to highlighted, an optional client radio choice (mounted only while the deal is `sent`), and a running-total strip (today / at go-live / then monthly). Adds two new optional `ProposalRenderContext` fields — `careTier` and `depositAllowed` (defaults to `true`) — read by `readProposalContext`; both are additive and backward compatible.
  • de67199: ProposalStatement: the big statement no longer overflows narrow phones. Its type floor is now min(4.5rem, 20vw) instead of a flat 4.5rem — it only bites below ~400px (320px → 64px, 390px → 78px at an 18px root), so tablet and desktop sizes are unchanged, and an unbreakable long word wraps as a last resort instead of pushing the page sideways.
  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`, and media fields take their `relationTo` from `mediaRelation(config)` instead of a local `as CollectionSlug` cast. Behavior and signatures are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helpers.
v0.1.2patch

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.1.1patch

Three layout fixes from the first real proposal: - An aside beside a hung box (a checklist plate with its "what it does not touch" note) now stacks under the box between 1024px and 1279px, and hangs beside it from 1280px. At 1024 the side track left the aside 57px wide, and it overflowed the page by 13px. - The statement band's body stays flush with its headline even when the host's rich-text adapter wraps it in a centering container (it sat about 190px to the right inside the wide column). - Checklist plates and offer cards use 20px inner padding below 1024px and match the 50px hang from 1024px up, where their text has to line up with the measure. At 50px everywhere, a phone squeezed a plate's text column to about 150px.

  • Three layout fixes from the first real proposal: - An aside beside a hung box (a checklist plate with its "what it does not touch" note) now stacks under the box between 1024px and 1279px, and hangs beside it from 1280px. At 1024 the side track left the aside 57px wide, and it overflowed the page by 13px. - The statement band's body stays flush with its headline even when the host's rich-text adapter wraps it in a centering container (it sat about 190px to the right inside the wide column). - Checklist plates and offer cards use 20px inner padding below 1024px and match the 50px hang from 1024px up, where their text has to line up with the measure. At 50px everywhere, a phone squeezed a plate's text column to about 150px.
v0.1.0minor

e044594: Initial release. Twelve free-tier proposal blocks (cover, letter, chapter, stat strip, statement, monthly, media spread, doors, checklist, offers, asks, close) on the house grid, a `ProposalRenderContext` for live deal figures and `{token}` fills, and the "Letter with plates" starting template.

  • e044594: Initial release. Twelve free-tier proposal blocks (cover, letter, chapter, stat strip, statement, monthly, media spread, doors, checklist, offers, asks, close) on the house grid, a `ProposalRenderContext` for live deal figures and `{token}` fills, and the "Letter with plates" starting template.

Blocks Local Pack

v0.14.0
v0.14.0minor

2288bb9: Three new variants for the local blocks: a Session Board for the class schedule, a package card for the price list, and a cards layout for logistics. Every existing variant renders exactly as before. `local-class-schedule` gains a `board` variant: today's sessions as ticket rows (time, name with its age rule and note, the session price, and a state chip: "On now, until 7:00 PM", "Starts 7:30 PM", "Finished" or "Sold out"). Sessions come from tome-local's `sessionsThisWeek` filtered to today in the schedule's timezone, so finished sessions stay (dimmed) unless `showFinished` is off. New fields: `sessionLine` (a clock line with `{date}`, `{time}` and `{closes}`), `footnote` (text with an optional link), `showFinished`, and the copy fields `labels.now` and `labels.starts`. The board renders nothing without a timezone or without a session today. "Sold out" shows only from a hand-entered availability line that has not expired. A small client island keeps the chips and the clock true at session boundaries and hides a cached board once its date has passed (`data-board-date`). `local-price-list` gains a `package` variant built on the fee card: a headline price with the headcount ("for 10 guests"), the price of each extra, the length, a deposit with its refund note, and an inclusions list, all optional, with prices in minor units through the existing formatter. Add-ons are items that set only a name, a line and a price. Item fields: `headcount`, `extra`, `duration`, `deposit`, `inclusions`. The fee-card fields `per`, `emphasis`, `kicker` and `action` and the block opener now also show for `package`; the opener group in the admin is renamed "Fee cards". Fact names live under `labels` (`packageLength`, `packageExtra`, `packageDeposit`, `packageNoDeposit`, `packageHeadcount`). `local-logistics` gains a `cards` variant: the address, one card per visit-note group, the hours and the profile's service areas and credentials as equal cards, then link cards (`links[]` with a title, a line and a button, such as a waiver). New fields: `links`, `findLabel` and `newTabNotice`. The variant shows no map. Hooks are listed in the package README: `data-board-*` (and `data-schedule-board`), `data-package-*`, and `data-logistics-cards`, `data-logistics-card` and `data-logistics-link-action`. Needs `@wabbit/tome-local` 0.7.0 or later for the session price.

  • 2288bb9: Three new variants for the local blocks: a Session Board for the class schedule, a package card for the price list, and a cards layout for logistics. Every existing variant renders exactly as before. `local-class-schedule` gains a `board` variant: today's sessions as ticket rows (time, name with its age rule and note, the session price, and a state chip: "On now, until 7:00 PM", "Starts 7:30 PM", "Finished" or "Sold out"). Sessions come from tome-local's `sessionsThisWeek` filtered to today in the schedule's timezone, so finished sessions stay (dimmed) unless `showFinished` is off. New fields: `sessionLine` (a clock line with `{date}`, `{time}` and `{closes}`), `footnote` (text with an optional link), `showFinished`, and the copy fields `labels.now` and `labels.starts`. The board renders nothing without a timezone or without a session today. "Sold out" shows only from a hand-entered availability line that has not expired. A small client island keeps the chips and the clock true at session boundaries and hides a cached board once its date has passed (`data-board-date`). `local-price-list` gains a `package` variant built on the fee card: a headline price with the headcount ("for 10 guests"), the price of each extra, the length, a deposit with its refund note, and an inclusions list, all optional, with prices in minor units through the existing formatter. Add-ons are items that set only a name, a line and a price. Item fields: `headcount`, `extra`, `duration`, `deposit`, `inclusions`. The fee-card fields `per`, `emphasis`, `kicker` and `action` and the block opener now also show for `package`; the opener group in the admin is renamed "Fee cards". Fact names live under `labels` (`packageLength`, `packageExtra`, `packageDeposit`, `packageNoDeposit`, `packageHeadcount`). `local-logistics` gains a `cards` variant: the address, one card per visit-note group, the hours and the profile's service areas and credentials as equal cards, then link cards (`links[]` with a title, a line and a button, such as a waiver). New fields: `links`, `findLabel` and `newTabNotice`. The variant shows no map. Hooks are listed in the package README: `data-board-*` (and `data-schedule-board`), `data-package-*`, and `data-logistics-cards`, `data-logistics-card` and `data-logistics-link-action`. Needs `@wabbit/tome-local` 0.7.0 or later for the session price.
v0.13.0minor

f321f0b: The class schedule block shows a session price when a row has one. The price prints on its own line under the time and age line, with a `data-class-price` hook and tabular figures: "$18", "From $12", "$12–$20", "Free" or "Price on request", followed by the row's "per" text. Nothing renders for a row with no price, and the calendar download never includes the price. The inline class rows gain the same `price` group as the shared timetable, the block gains a `currency` field (the shared timetable's currency wins when the block reads it), and the block's copy gains `priceFrom`, `priceFree` and `priceRequest` labels. Prices use the price list's minor-units rule and formatter. Needs `@wabbit/tome-local` 0.7.0 or later.

  • f321f0b: The class schedule block shows a session price when a row has one. The price prints on its own line under the time and age line, with a `data-class-price` hook and tabular figures: "$18", "From $12", "$12–$20", "Free" or "Price on request", followed by the row's "per" text. Nothing renders for a row with no price, and the calendar download never includes the price. The inline class rows gain the same `price` group as the shared timetable, the block gains a `currency` field (the shared timetable's currency wins when the block reads it), and the block's copy gains `priceFrom`, `priceFree` and `priceRequest` labels. Prices use the price list's minor-units rule and formatter. Needs `@wabbit/tome-local` 0.7.0 or later.
v0.12.0minor

e2f64c4: A save now moves renamed keys to their American names and empties the old ones. This is the second of three steps toward American spelling in stored values. On the class schedule block and collection, `enrolLabel`, `enrolHref`, `enrol` and `labels.enrol` move to `enrollLabel`, `enrollHref`, `enroll` and `labels.enroll`. On the event list board, `labels.cancelled` moves to `labels.canceled`. Anything that still writes an old key lands in the new one. This fixes clearing a field: a value sent for the new key, even a blank one, wins and the old value is emptied, so it no longer comes back on the next read or shows on the public page. The old group and label no longer have default values, so a new block stores nothing under them. Renderers still read the old keys, so a block that hasn't been saved or backfilled looks the same. The class schedule and event calendar demos use the new names. **BREAKING:** `@wabbit/tome-blocks-local-pack` now needs `@wabbit/tome-local` 0.6.0 or later, which exports these save hooks from `@wabbit/tome-local/classes`.

  • e2f64c4: A save now moves renamed keys to their American names and empties the old ones. This is the second of three steps toward American spelling in stored values. On the class schedule block and collection, `enrolLabel`, `enrolHref`, `enrol` and `labels.enrol` move to `enrollLabel`, `enrollHref`, `enroll` and `labels.enroll`. On the event list board, `labels.cancelled` moves to `labels.canceled`. Anything that still writes an old key lands in the new one. This fixes clearing a field: a value sent for the new key, even a blank one, wins and the old value is emptied, so it no longer comes back on the next read or shows on the public page. The old group and label no longer have default values, so a new block stores nothing under them. Renderers still read the old keys, so a block that hasn't been saved or backfilled looks the same. The class schedule and event calendar demos use the new names. **BREAKING:** `@wabbit/tome-blocks-local-pack` now needs `@wabbit/tome-local` 0.6.0 or later, which exports these save hooks from `@wabbit/tome-local/classes`.
v0.11.1patch

3606dc7: The `enrol` names and two option values now accept their American spellings. This is the first of three steps, and nothing that gets written changes yet. Class schedule rows and the class schedule block gain `enrollLabel`, `enrollHref`, the `enroll` link group and the `labels.enroll` copy key. The renderer reads each new key first and falls back to the old `enrol` key, field by field. The old fields are hidden in the admin, not removed, so a save never drops a stored value, and the new fields show the stored value while the new key is empty. The credential kind `license` joins `licence`, and the price list tag `house-favorite` joins `house-favourite`. A credentials strip claim bound to either spelling of the kind matches both, and both tag values show the same label. The admin shows both options, because Payload cannot hide one select option; the old one is labeled "(legacy spelling)".

  • 3606dc7: The `enrol` names and two option values now accept their American spellings. This is the first of three steps, and nothing that gets written changes yet. Class schedule rows and the class schedule block gain `enrollLabel`, `enrollHref`, the `enroll` link group and the `labels.enroll` copy key. The renderer reads each new key first and falls back to the old `enrol` key, field by field. The old fields are hidden in the admin, not removed, so a save never drops a stored value, and the new fields show the stored value while the new key is empty. The credential kind `license` joins `licence`, and the price list tag `house-favorite` joins `house-favourite`. A credentials strip claim bound to either spelling of the kind matches both, and both tag values show the same label. The admin shows both options, because Payload cannot hide one select option; the old one is labeled "(legacy spelling)".
v0.11.0minor

65020e0: **BREAKING:** the class-schedule enroll button hook is now `data-schedule-enroll` (was `data-schedule-enrol`). Update any site CSS or script that targets the old attribute. The block tags now read `enrollment` and `license`. Stored field names are unchanged.

  • 65020e0: **BREAKING:** the class-schedule enroll button hook is now `data-schedule-enroll` (was `data-schedule-enrol`). Update any site CSS or script that targets the old attribute. The block tags now read `enrollment` and `license`. Stored field names are unchanged.
  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.10.0minor

dedba9f: The locations block now labels sample places once on the map instead of on every card, and can write hours as "Mon to Fri 7:30 AM to 5:30 PM". When a live map is shown, the sample label is printed on the map (`data-map-sample`) and the cards stay clean; with no map each sample place still carries its own label. The new optional `hoursRangeWord` field (for example "to") replaces the en dash between days and between times; empty keeps the dash.

  • dedba9f: The locations block now labels sample places once on the map instead of on every card, and can write hours as "Mon to Fri 7:30 AM to 5:30 PM". When a live map is shown, the sample label is printed on the map (`data-map-sample`) and the cards stay clean; with no map each sample place still carries its own label. The new optional `hoursRangeWord` field (for example "to") replaces the en dash between days and between times; empty keeps the dash.
v0.9.0minor

a3a2e5d: `LocalLocations` takes an optional `renderOfficeExtra(office)` prop: what it returns is appended inside each place, after its actions. A wrapper that knows more about a place, such as who is seen there, adds it through the slot instead of reworking the returned markup. Unset, or returning nothing, the output is byte for byte what it was. It is a server-side function prop and is never passed to a client island.

  • a3a2e5d: `LocalLocations` takes an optional `renderOfficeExtra(office)` prop: what it returns is appended inside each place, after its actions. A wrapper that knows more about a place, such as who is seen there, adds it through the slot instead of reworking the returned markup. Unset, or returning nothing, the output is byte for byte what it was. It is a server-side function prop and is never passed to a client island.
v0.8.0minor

0517734: **BREAKING:** requires `@wabbit/tome-local` 0.4 or newer; adds two location blocks, a `location` data source on three existing blocks, and an optional live map. **Migration:** upgrade `@wabbit/tome-local` to 0.4 or newer. Nothing else is required: with the local layer's `locations` option off, the existing blocks' schema, hook and output are unchanged (goldens hold this), and the two new blocks simply appear in the pack. New blocks `local-locations` (the places as a list, each with a live open-or-closed status in its own time zone, one place renders as that one place) and `local-desk-strip` (every office's status, the desk phone and one book action). Build `local-logistics`, `local-live-status` and `local-credentials` with `locationsSlug` and their `dataSource` gains `location`. Build `local-logistics` or `local-locations` with `liveMap: true` and `mapMode` gains `live`; import `@wabbit/tome-blocks-local-pack/live-map` once on the server to register the map island over `@wabbit/tome-maps` (a new optional peer). The map loads only after the visitor presses "Show map", the directions links stand in until then and if it cannot load, and a place that hides its address is never pinned. `local-price-list` gains no location source. The new blocks render hooks only; the theme owns the look.

  • 0517734: **BREAKING:** requires `@wabbit/tome-local` 0.4 or newer; adds two location blocks, a `location` data source on three existing blocks, and an optional live map. **Migration:** upgrade `@wabbit/tome-local` to 0.4 or newer. Nothing else is required: with the local layer's `locations` option off, the existing blocks' schema, hook and output are unchanged (goldens hold this), and the two new blocks simply appear in the pack. New blocks `local-locations` (the places as a list, each with a live open-or-closed status in its own time zone, one place renders as that one place) and `local-desk-strip` (every office's status, the desk phone and one book action). Build `local-logistics`, `local-live-status` and `local-credentials` with `locationsSlug` and their `dataSource` gains `location`. Build `local-logistics` or `local-locations` with `liveMap: true` and `mapMode` gains `live`; import `@wabbit/tome-blocks-local-pack/live-map` once on the server to register the map island over `@wabbit/tome-maps` (a new optional peer). The map loads only after the visitor presses "Show map", the directions links stand in until then and if it cannot load, and a place that hides its address is never pinned. `local-price-list` gains no location source. The new blocks render hooks only; the theme owns the look.
  • 666f4eb: `safeHref` now comes from `@wabbit/tome-core/utilities/safeHref`; the pack re-exports it from its existing internal path. Behavior is unchanged. The `@wabbit/tome-core` peer range is raised to `>=1.32.0`, the release that exports the new subpath.
v0.7.1patch

ad9f2f3: Facility row actions can name their place, and "Show on plan" can link to the full plan on another page. The four row action labels (Show on plan, Directions, Inquire, Details) on `local-facilities` now fill `{name}` and `{plate}`. A label that carries `{name}` is the link's accessible name by itself, so a screen reader no longer hears the name twice ("See Field 4 on the full plan, Field 4"). Labels without the token print exactly as before, with the name appended to the accessible name. `showOnPlanHrefTemplate` (optional, `{key}` filled, for example `/site#plan-{key}`) sets where "Show on plan" goes, which lets a `locator` inset on an event page link to the site's full plan. Blank keeps the link on the marker on the same page, as before.

  • ad9f2f3: Facility row actions can name their place, and "Show on plan" can link to the full plan on another page. The four row action labels (Show on plan, Directions, Inquire, Details) on `local-facilities` now fill `{name}` and `{plate}`. A label that carries `{name}` is the link's accessible name by itself, so a screen reader no longer hears the name twice ("See Field 4 on the full plan, Field 4"). Labels without the token print exactly as before, with the name appended to the accessible name. `showOnPlanHrefTemplate` (optional, `{key}` filled, for example `/site#plan-{key}`) sets where "Show on plan" goes, which lets a `locator` inset on an event page link to the site's full plan. Blank keeps the link on the marker on the same page, as before.
v0.7.0minor

99c0bba: New block: `local-facilities`, the places at a venue as a keyed list beside an illustrated site plan, free in the local pack. Additive: no existing block changes, and a page without the new block renders exactly as before. - **Three variants.** `plan` (the default) sets the list of places beside a site plan image with a key-plate marker for each place; `list` is the list alone; `locator` shows one place (`locateKey`, else the first row) with only its marker, for an event page's "where it is" inset. - **Places.** `facilities[]` holds 1 to 60 rows: `name`, `key`, `plate`, `type` (rows group under it, with a row of category links, `showGroupNav`), `scheduleKey`, `hoursText`, `accessibility`, `status` with `statusNote` and `statusExpires`, `lat` and `lng`, the marker's `x` and `y` (percent of the plan image), a photo with its alt text, `blurb`, `href` and `bookable`. - **The key.** `^[a-z0-9][a-z0-9-]{0,23}$`, unique in the block (a duplicate is refused). Rows are `#<idPrefix>facility-<key>` and markers `#<idPrefix>plan-<key>`, the same id forms the org pack's event rows link to. - **Honest states.** The open state comes only from a named schedule in the business profile ("Open now · closes at 9:00 PM"); a row without one makes no claim, and a schedule that cannot be read is never shown as closed. A `limited` or `closed` status requires an expiry and stops printing after it. `lat` and `lng` are both-or-neither and give a keyless Directions link. Alt text is required with a photo and with the plan. The plan always carries its "Illustrative plan" label. - **Works without script.** The list comes first in the page, the full plan follows it, every marker is a link to its row and "Show on plan" is a link to its marker. - **The island.** Scroll-spy lights the marker of the row in view; a marker click scrolls its row to the middle and focuses its heading; the open state and the status stay true on a cached page (a silent correction at mount, then one polite announcement for a change after load); reduced motion turns every scroll into an instant jump. - **Plan marker hooks.** Each plan marker carries `data-facility-name` (the plain name, so a callout can read "Field 4" while the accessible name keeps the plate) and `data-facility-status` (`limited` or `closed` while a status is live, absent otherwise; the island drops it at expiry, and the phone sheet's marker buttons carry both). - **Landing on a link.** A page opened at, or moved to, `#plan-<key>` or `#facility-<key>` lights that place, flashes its marker briefly, centers the row and focuses its heading, and holds scroll-spy until the visitor's next input. `idPrefix` namespaces every id when a page holds two facilities blocks. - **Phones.** Below 48rem the plan becomes a short card pinned to the bottom of the screen with the active marker and an "Open full plan" button. "Show on plan" opens a full-screen sheet titled "Plan of {place}" (the profile's business name) with zoom in, zoom out and "Fit", pan by scrolling, and the key list; focus stays inside until Escape or Close and then returns. A link landing on a phone shows the card, not the sheet. - **Locator open state.** `showOpenState: false` drops the open-state line and its `data-facility-open` hook, for a locator beside an event on another day. - **US spelling.** The booking link is `inquiryHrefTemplate` (`{key}` fills), its default label is "Inquire" and its hook is `a[data-facility-inquire]`. - **Every word is a label.** `labels` covers the actions, the open-state words, the status chips, the category nav, "Open full plan", the sheet title, Close, the zoom controls and "Fit", the pan area, the key heading, the list and marker names and the plan's alt text. Public hooks for themes are listed in the README under Local Facilities. On a SQL database the new block adds its own tables when a site regenerates its migrations; nothing existing changes. **Checkpoint A fixes.** The facilities changes below are defaults of the new block; `local-live-status` gains two opt-in options and its default output is unchanged. - **Facilities: a closure replaces the open-state line.** A live `closed` status (unexpired) now replaces "Open now · closes at 9:00 PM", so a closed row never says both. `limited` keeps both. The island does the same on a cached page and writes the open state back at expiry; a closed row has no `data-facility-open` while the closure is live. - **Facilities: layout column hook.** The header and layout read `--tome-facilities-column` (default `content-start / content-end`, as before). A host whose page grid has no named lines sets it to `1 / -1`; a browser without subgrid falls back to `1 / -1`. - **Facilities: plan aspect ratio.** When the stored plan upload has both `width` and `height`, the figure carries `--fc-plan-ratio` and the image takes that aspect ratio, so an SVG with no intrinsic size is not 0px tall in WebKit. Nothing changes without both. - **Live status: two opt-in options.** `showSeparator` (checkbox, off) renders `span[data-live-sep]`, an aria-hidden middle dot between the lead and the detail ("Open now · closes at 9:00 PM"); leave it off when the theme draws its own. `clockMinutes` (`auto`, the default, or `always`) keeps the minutes on the hour ("9:00 PM" in the locale style, "9:00" in compact). Unset, both render exactly as before. `local-logistics` already shows minutes in its hours table and has no open-now sentence, and the facilities open-state line is unchanged by default.

  • 99c0bba: New block: `local-facilities`, the places at a venue as a keyed list beside an illustrated site plan, free in the local pack. Additive: no existing block changes, and a page without the new block renders exactly as before. - **Three variants.** `plan` (the default) sets the list of places beside a site plan image with a key-plate marker for each place; `list` is the list alone; `locator` shows one place (`locateKey`, else the first row) with only its marker, for an event page's "where it is" inset. - **Places.** `facilities[]` holds 1 to 60 rows: `name`, `key`, `plate`, `type` (rows group under it, with a row of category links, `showGroupNav`), `scheduleKey`, `hoursText`, `accessibility`, `status` with `statusNote` and `statusExpires`, `lat` and `lng`, the marker's `x` and `y` (percent of the plan image), a photo with its alt text, `blurb`, `href` and `bookable`. - **The key.** `^[a-z0-9][a-z0-9-]{0,23}$`, unique in the block (a duplicate is refused). Rows are `#<idPrefix>facility-<key>` and markers `#<idPrefix>plan-<key>`, the same id forms the org pack's event rows link to. - **Honest states.** The open state comes only from a named schedule in the business profile ("Open now · closes at 9:00 PM"); a row without one makes no claim, and a schedule that cannot be read is never shown as closed. A `limited` or `closed` status requires an expiry and stops printing after it. `lat` and `lng` are both-or-neither and give a keyless Directions link. Alt text is required with a photo and with the plan. The plan always carries its "Illustrative plan" label. - **Works without script.** The list comes first in the page, the full plan follows it, every marker is a link to its row and "Show on plan" is a link to its marker. - **The island.** Scroll-spy lights the marker of the row in view; a marker click scrolls its row to the middle and focuses its heading; the open state and the status stay true on a cached page (a silent correction at mount, then one polite announcement for a change after load); reduced motion turns every scroll into an instant jump. - **Plan marker hooks.** Each plan marker carries `data-facility-name` (the plain name, so a callout can read "Field 4" while the accessible name keeps the plate) and `data-facility-status` (`limited` or `closed` while a status is live, absent otherwise; the island drops it at expiry, and the phone sheet's marker buttons carry both). - **Landing on a link.** A page opened at, or moved to, `#plan-<key>` or `#facility-<key>` lights that place, flashes its marker briefly, centers the row and focuses its heading, and holds scroll-spy until the visitor's next input. `idPrefix` namespaces every id when a page holds two facilities blocks. - **Phones.** Below 48rem the plan becomes a short card pinned to the bottom of the screen with the active marker and an "Open full plan" button. "Show on plan" opens a full-screen sheet titled "Plan of {place}" (the profile's business name) with zoom in, zoom out and "Fit", pan by scrolling, and the key list; focus stays inside until Escape or Close and then returns. A link landing on a phone shows the card, not the sheet. - **Locator open state.** `showOpenState: false` drops the open-state line and its `data-facility-open` hook, for a locator beside an event on another day. - **US spelling.** The booking link is `inquiryHrefTemplate` (`{key}` fills), its default label is "Inquire" and its hook is `a[data-facility-inquire]`. - **Every word is a label.** `labels` covers the actions, the open-state words, the status chips, the category nav, "Open full plan", the sheet title, Close, the zoom controls and "Fit", the pan area, the key heading, the list and marker names and the plan's alt text. Public hooks for themes are listed in the README under Local Facilities. On a SQL database the new block adds its own tables when a site regenerates its migrations; nothing existing changes. **Checkpoint A fixes.** The facilities changes below are defaults of the new block; `local-live-status` gains two opt-in options and its default output is unchanged. - **Facilities: a closure replaces the open-state line.** A live `closed` status (unexpired) now replaces "Open now · closes at 9:00 PM", so a closed row never says both. `limited` keeps both. The island does the same on a cached page and writes the open state back at expiry; a closed row has no `data-facility-open` while the closure is live. - **Facilities: layout column hook.** The header and layout read `--tome-facilities-column` (default `content-start / content-end`, as before). A host whose page grid has no named lines sets it to `1 / -1`; a browser without subgrid falls back to `1 / -1`. - **Facilities: plan aspect ratio.** When the stored plan upload has both `width` and `height`, the figure carries `--fc-plan-ratio` and the image takes that aspect ratio, so an SVG with no intrinsic size is not 0px tall in WebKit. Nothing changes without both. - **Live status: two opt-in options.** `showSeparator` (checkbox, off) renders `span[data-live-sep]`, an aria-hidden middle dot between the lead and the detail ("Open now · closes at 9:00 PM"); leave it off when the theme draws its own. `clockMinutes` (`auto`, the default, or `always`) keeps the minutes on the hour ("9:00 PM" in the locale style, "9:00" in compact). Unset, both render exactly as before. `local-logistics` already shows minutes in its hours table and has no open-now sentence, and the facilities open-state line is unchanged by default.
v0.6.2patch

1636ffa: The `local-class-finder` and `local-class-pathway` demos' trial links and the finder's ask form point at `#`, not an invented `/trial` route.

  • 1636ffa: The `local-class-finder` and `local-class-pathway` demos' trial links and the finder's ask form point at `#`, not an invented `/trial` route.
v0.6.1patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.6.0minor

d27b90c: **BREAKING:** `local-class-schedule` can now read the shared class timetable and gains opt-in controls for a booking-led timetable; the `@wabbit/tome-local` peer floor rises to `>=0.3.0`. **Migration:** upgrade `@wabbit/tome-local` to `^0.3.0` alongside this package. To use the shared timetable, build it with `createLocalLayer({ classSchedules: true })` and pass its slug when you build the block: `localClassScheduleBlock.block({ classSchedulesSlug: local.slugs.classSchedules })`. A site that does nothing renders exactly as before. - **Shared source.** `source: 'collection'` with a `schedule` relationship (and a virtual `scheduleData` filled by a per-request read hook) shows one `local-class-schedules` document, so a finder, a pathway and this timetable agree. The document wins for classes, term, breaks, timezone, week start, age bands and levels; the block keeps its copy and display options. "This week" and "finished" come from `sessionsThisWeek`. A missing, unpublished or unreadable document falls back to the block's own rows. `source` defaults to `inline`, and a stored block without it renders byte for byte as before. - **Age bands and the URL.** `ageFilter: 'bands'` lists age bands (the document's, or the block's own `ageBands[]`), matched by overlap. `?age=5-7` and `?level=` in the page address, else `preset`, choose the starting filters. - **Level stripe.** `levels[]` (the document's, or the block's own) places each class on its track, and the stripe is rendered as markup with its text beside it (`data-level-stripe`, `data-level-segment`, `data-level-step`, `data-level-steps`, `data-level-text`). Switch it off with `show.levelStripe`. - **Opt-in options**, each off or empty by default: `intro`; `summary` (a polite live region with a clear button); `emptyDays: 'note'` (the day keeps its column and says "No classes for {age} on {day}s"); `markNext` and `actionEmphasis: 'next'` (`data-schedule-next` on the soonest unfinished class with room, recomputed when the filter changes, and one filled enroll button); `data-schedule-age-fixed` and `data-schedule-level-fixed` on the root while a filter has a value; `showDates` (dates in the day headings and phone tabs); `labels.finished` (the word "Finished"), `labels.calendarRow` (a calendar link named for its class, day and time) and `calendarWhenFull: false`; `calendarLink` (one calendar file of the classes on show, a weekly series to the term's end with a skipped date per break); `moreLink`; `printLabel`. - **For consumers.** The package exports `scheduleFields`, `makeScheduleReadHook`, `loadScheduleForRequest` and `DEFAULT_CLASS_SCHEDULES_SLUG` for blocks that read the same document, and the shared `LevelStripe` markup is a server component with no new client island. **New block: `local-class-finder`.** A hero that answers "which class do we start in?" in place. Variant `ask` is the home hero: two pill questions (an age band, then experience) resolve through `rules[]` (first match wins) to one result card with the said-back line, "Start in {class}", the class's level stripe and level line ("Level 1 of 5 · 45 minutes · 1 coach to 8"), this week's remaining times, a spots line, the trial button and a timetable link that carries `?age=<band>`. The server renders the default answer, so it works without script; a small island answers taps in place (a polite live region, no motion), re-reads the clock after mount and at each expiry, and arms the stripe's once-in-view fill only when motion is allowed. Variant `level` is a program page's hero for one level: breadcrumb, h1, tags with the stripe, a lede, a plain GET form that carries the age and class to the trial page, and the spots line. It reads the shared `local-class-schedules` timetable (`source: 'collection'`, through the same read hook and virtual `scheduleData` field as the schedule, memoised per request) or its own inline `ageBands`, `levels`, `classes`, `timezone`, `term` and `breaks`. Pass `localClassFinderBlock.block({ classSchedulesSlug })` when `@wabbit/tome-local` built the collection; without it the block offers inline data only and adds no relationship. A spots claim or live-line message shows only while its expiry is ahead, on the server and after mount. New public surface: `localClassFinderBlock`, `LocalClassFinder`, the `LocalClassFinderData` types, and from `./server` `LocalClassFinderFromSchedule` and `getClassFinderData`. A stored page without the new block renders as before. **New block: `local-class-pathway`.** The levels of a class program for a parent, reading the same shared `local-class-schedules` timetable as the finder and the schedule (`source: 'inline' | 'collection'`, the same read hook and virtual `scheduleData`; pass `localClassPathwayBlock.block({ classSchedulesSlug })`). - **Variants.** `bands`: age band pills (`aria-pressed`) over that band's levels as cards, each with its stripe and "Step 1 of 2" / "Level 1 of 5" text, "Who it's for", "Moves up when" and this week's unfinished times; every band shows without script, and `initialBand` starts one selected. `cards`: the levels named in `levelNames[]` (the step-0 rename: an inline pathway also carries the full `levels[]`) as cards with `action { label, hrefPattern }` after up to two `media[]` photos at one height, which is the "little movers" band, so no split view is needed. `ladder`: the `current` level's track as rungs with "You're here" (`aria-current="step"`), then exactly one card for the next level on the same track (none at the top), plus an optional `aside { title, text }`. - **Next band.** `bands` takes an opt-in `bandTeaser`: under the selected band, one card (`data-pathway-next-band`) for the next age band's entry level, with a kicker from `labels.nextBand` ("Next, from age {minAge}"), its level line, `forWho` and an "About {class}" link when it has an `href`. It follows the pills; the last band has none. - **Fields and wording.** `kicker`, `heading`, `intro`; `teaserLabel`; `labels` for every fixed phrase; `trackLabels[]` for per-track position wording; `locale` and `timeStyle`. The inline source adds `forWho` and `movesUpWhen` to each level. - **Hooks.** `data-class-pathway`, `data-pathway-bands` / `-band`, `-group`, `-card`, `-step`, `-for`, `-moves-up`, `-times` / `-time`, `-action`, `-media`, `-ladder`, `-rung`, `-here`, `-teaser` and `-aside`. Cards, rungs and photos carry `data-tome-reveal-item`. - **Public surface.** `localClassPathwayBlock`, `LocalClassPathway`, the `LocalClassPathwayData` types. - **New columns.** The block adds tables and columns, so a site with migrations generates one. **Fix: the level stripe no longer flashes empty at load.** A stripe already in view when the island hydrates used to be drawn by the server, blanked for about 41ms when the island armed it, then sprung back in. The shared `armStripeFill` now leaves a stripe that is in view at hydration drawn and never arms it; only a stripe below the fold (or in a band not yet shown) arms and fills when it enters view. This applies to the finder's `ask` and `level` variants and to the pathway; reduced motion is unchanged (always drawn). **Per-context wording and theme tokens (all opt-in; a stored block without them renders byte for byte as before).** - **`ageBands[].pillLabel`, `selectLabel`, `saidLabel`** on the finder's own bands: the pill ("5–7"), the timetable's filter option ("Ages 5–7") and the said-back line's `{age}` ("5 to 7"). Each falls back to `label`. `local-class-schedule` gains `ageBands[].selectLabel` for its filter option. With `source: 'collection'` the shared timetable's bands carry them too (`@wabbit/tome-local` stores and normalizes the three fields): the document wins, and a band without them falls back to its `label`. `trackLabels[]` stays a block field. - **`liveLine.clauseTemplate`** on the finder: wording for the term clause with `{term}`, `{start}`, `{end}` and `{note}`, e.g. "{term} runs {start} to {end}, {note}". A token with no value drops with the separator beside it, and a template that needs dates on a term without them falls back to the default clause. - **`trackLabels[]`** on the finder (`track`, `levelOf`): the level position wording per track, so preschool reads "Step 1 of 2" while gymnastics keeps "Level 1 of 5". A track not listed uses `labels.levelOf`. A track with one level still shows no stripe. - **`timeStyle`** was already stored on the finder (`compact` drops AM/PM, "Tue 4:30"); it needs only to be set. - **Theme tokens.** The level stripe's segments read `--tome-level-stripe-w`, `--tome-level-stripe-h` and `--tome-level-stripe-gap` (finder and schedule), and the trial button reads `--tome-finder-cta-min-h` (never below the 44px target). Each has the old rem value as its fallback, so a theme's px sizes win over the root-relative ones. - **New columns.** The new fields add columns to the block's tables, so a site with migrations generates one. **A rolling list, the next-sessions card list, and the phone timetable (all opt-in; a stored block without the new fields renders byte for byte as before).** - **`range: 'next'` with `limit`** on the `list` variant shows the next dated sessions (default 4) across this week and next: one section per date, each row dated, no day strip, breaks skipped and the term's end respected, narrowed by `preset.level` or `preset.age`. The root carries `data-schedule-range="next"` and each day section `data-date`. `calendarLink` on it downloads every slot of the filtered classes. The two new fields add `range` and `limit` columns to the block's tables, so a site with migrations generates one. - **`LocalNextSessions`**, a server component (not a block), lists a level's next sessions for a program page's next-step card: `data-next-sessions`, `data-next-session`, `data-next-session-date`, `data-next-session-time`, `data-schedule-next`, `data-schedule-spots`. `./server` adds `LocalNextSessionsFromSchedule`, which reads the document and the profile's closed dates. Both read tome-local's `nextSessions`, so the card, the finder and the timetable agree on what is next. - **The phone timetable.** The day strip now sticks to the whole block (the controls box steps aside below 768px) on a ground set by `--tome-schedule-strip-bg`; a tab that shows its date is at least 55 by 62px (`--tome-schedule-tab-min-w`, `--tome-schedule-tab-min-h`); `labels.noClassesBand` can use `{ageLower}` ("No classes for ages 5–7 on Fridays"); a finished class (with `labels.finished` set) keeps an outline pill; printing shows the whole week whatever the screen, and the controls and strip are not printed. - **Fix: the age filter overflowed below 374px.** `.filter` and `.select` now shrink (`min-width: 0`, `max-width: 100%`) and clip a long option such as "Under 3, with a grown-up" with an ellipsis, so nothing overflows at 320px. **New block: `local-credentials`.** What a business can prove, in two variants. `strip` is a row of short checkable lines; `panel` is a rounded chapter with ratios as large tabular numerals, up to two policy lists, a credentials ledger (kind, issuer, what it covers, valid, verify) and a "policy last reviewed" line. - **Reads the business profile.** `dataSource: 'profile'` (the default) takes the credentials from `@wabbit/tome-local`'s profile through a virtual `profile` field and the same read hook as the other profile-aware blocks; the block's own `credentials[]` fill in when the profile has none, and `dataSource: 'inline'` uses them alone. A credential past its `expiresOn` date never renders. The profile slice now carries each credential's `validFrom`, `expiresOn`, `verifyUrl` and `sample`; the keys already there are unchanged. - **Strip lines can be bound to a credential.** A line with a `kind` shows only while an unexpired credential of that kind exists; a line without one is your own statement. - **Samples are labeled.** A credential ticked Sample in the profile (tome-local's P-13 checkbox) or in the block's rows shows the label ("Sample" by default, `sampleLabel`) in the ledger, and a strip line reads Sample when it is ticked itself or when every credential of its kind is a sample. - **Accessible by construction.** A real table with a caption, column and row headers, a "Verify {credential}" link per row, decorative check marks beside text, and a ledger that stacks below 768px with each value led by its column name. No script, no motion, no colored rules. New public surface: `localCredentialsBlock`, `LocalCredentials`, the `LocalCredentialsData` types and `LocalBlockCredential`. A stored page without the new block renders as before. **New variant: `local-price-list` `term-fee`.** Fee cards for a business that sells a free trial and then a term. The cards share one grid and one height with their buttons on one line; the recommended card (`emphasis: 'primary'`) is marked by its surface, padding, weight and the only filled button, never by a colored rule. A card with two or more priced variants gets a classes-a-week picker (a radiogroup) that swaps the price and its "per" line; without script the choices read as a plain list. Prices and the "per" text use tabular figures, and whole amounts print without cents. - **New fields, all optional and shown only for this variant:** on the list `kicker`, `heading`, `intro`, `pickerLabel`, `includesLabel`, `excludesLabel`, `objections[]` and `note`; on each item `emphasis`, `kicker`, `includes`, `excludes`, `includesLabel`, `excludesLabel` and `action { label, href }`; on each price `per`. The other three variants never read them, and their stored renders are byte for byte as before (the pack's golden covers `card-grid`, `ledger` and `teaser`). - **Hooks:** `data-fee-card` (`data-fee-emphasis`), `data-fee-kicker`, `data-fee-name`, `data-fee-price`, `data-fee-per`, `data-fee-picker`, `data-fee-option`, `data-fee-ladder`, `data-fee-includes`, `data-fee-excludes`, `data-fee-action`, `data-fee-objections`, `data-fee-objection`, `data-fee-note`. - **New columns.** The new fields add columns to the block's tables, so a site with migrations generates one.

  • d27b90c: **BREAKING:** `local-class-schedule` can now read the shared class timetable and gains opt-in controls for a booking-led timetable; the `@wabbit/tome-local` peer floor rises to `>=0.3.0`. **Migration:** upgrade `@wabbit/tome-local` to `^0.3.0` alongside this package. To use the shared timetable, build it with `createLocalLayer({ classSchedules: true })` and pass its slug when you build the block: `localClassScheduleBlock.block({ classSchedulesSlug: local.slugs.classSchedules })`. A site that does nothing renders exactly as before. - **Shared source.** `source: 'collection'` with a `schedule` relationship (and a virtual `scheduleData` filled by a per-request read hook) shows one `local-class-schedules` document, so a finder, a pathway and this timetable agree. The document wins for classes, term, breaks, timezone, week start, age bands and levels; the block keeps its copy and display options. "This week" and "finished" come from `sessionsThisWeek`. A missing, unpublished or unreadable document falls back to the block's own rows. `source` defaults to `inline`, and a stored block without it renders byte for byte as before. - **Age bands and the URL.** `ageFilter: 'bands'` lists age bands (the document's, or the block's own `ageBands[]`), matched by overlap. `?age=5-7` and `?level=` in the page address, else `preset`, choose the starting filters. - **Level stripe.** `levels[]` (the document's, or the block's own) places each class on its track, and the stripe is rendered as markup with its text beside it (`data-level-stripe`, `data-level-segment`, `data-level-step`, `data-level-steps`, `data-level-text`). Switch it off with `show.levelStripe`. - **Opt-in options**, each off or empty by default: `intro`; `summary` (a polite live region with a clear button); `emptyDays: 'note'` (the day keeps its column and says "No classes for {age} on {day}s"); `markNext` and `actionEmphasis: 'next'` (`data-schedule-next` on the soonest unfinished class with room, recomputed when the filter changes, and one filled enroll button); `data-schedule-age-fixed` and `data-schedule-level-fixed` on the root while a filter has a value; `showDates` (dates in the day headings and phone tabs); `labels.finished` (the word "Finished"), `labels.calendarRow` (a calendar link named for its class, day and time) and `calendarWhenFull: false`; `calendarLink` (one calendar file of the classes on show, a weekly series to the term's end with a skipped date per break); `moreLink`; `printLabel`. - **For consumers.** The package exports `scheduleFields`, `makeScheduleReadHook`, `loadScheduleForRequest` and `DEFAULT_CLASS_SCHEDULES_SLUG` for blocks that read the same document, and the shared `LevelStripe` markup is a server component with no new client island. **New block: `local-class-finder`.** A hero that answers "which class do we start in?" in place. Variant `ask` is the home hero: two pill questions (an age band, then experience) resolve through `rules[]` (first match wins) to one result card with the said-back line, "Start in {class}", the class's level stripe and level line ("Level 1 of 5 · 45 minutes · 1 coach to 8"), this week's remaining times, a spots line, the trial button and a timetable link that carries `?age=<band>`. The server renders the default answer, so it works without script; a small island answers taps in place (a polite live region, no motion), re-reads the clock after mount and at each expiry, and arms the stripe's once-in-view fill only when motion is allowed. Variant `level` is a program page's hero for one level: breadcrumb, h1, tags with the stripe, a lede, a plain GET form that carries the age and class to the trial page, and the spots line. It reads the shared `local-class-schedules` timetable (`source: 'collection'`, through the same read hook and virtual `scheduleData` field as the schedule, memoised per request) or its own inline `ageBands`, `levels`, `classes`, `timezone`, `term` and `breaks`. Pass `localClassFinderBlock.block({ classSchedulesSlug })` when `@wabbit/tome-local` built the collection; without it the block offers inline data only and adds no relationship. A spots claim or live-line message shows only while its expiry is ahead, on the server and after mount. New public surface: `localClassFinderBlock`, `LocalClassFinder`, the `LocalClassFinderData` types, and from `./server` `LocalClassFinderFromSchedule` and `getClassFinderData`. A stored page without the new block renders as before. **New block: `local-class-pathway`.** The levels of a class program for a parent, reading the same shared `local-class-schedules` timetable as the finder and the schedule (`source: 'inline' | 'collection'`, the same read hook and virtual `scheduleData`; pass `localClassPathwayBlock.block({ classSchedulesSlug })`). - **Variants.** `bands`: age band pills (`aria-pressed`) over that band's levels as cards, each with its stripe and "Step 1 of 2" / "Level 1 of 5" text, "Who it's for", "Moves up when" and this week's unfinished times; every band shows without script, and `initialBand` starts one selected. `cards`: the levels named in `levelNames[]` (the step-0 rename: an inline pathway also carries the full `levels[]`) as cards with `action { label, hrefPattern }` after up to two `media[]` photos at one height, which is the "little movers" band, so no split view is needed. `ladder`: the `current` level's track as rungs with "You're here" (`aria-current="step"`), then exactly one card for the next level on the same track (none at the top), plus an optional `aside { title, text }`. - **Next band.** `bands` takes an opt-in `bandTeaser`: under the selected band, one card (`data-pathway-next-band`) for the next age band's entry level, with a kicker from `labels.nextBand` ("Next, from age {minAge}"), its level line, `forWho` and an "About {class}" link when it has an `href`. It follows the pills; the last band has none. - **Fields and wording.** `kicker`, `heading`, `intro`; `teaserLabel`; `labels` for every fixed phrase; `trackLabels[]` for per-track position wording; `locale` and `timeStyle`. The inline source adds `forWho` and `movesUpWhen` to each level. - **Hooks.** `data-class-pathway`, `data-pathway-bands` / `-band`, `-group`, `-card`, `-step`, `-for`, `-moves-up`, `-times` / `-time`, `-action`, `-media`, `-ladder`, `-rung`, `-here`, `-teaser` and `-aside`. Cards, rungs and photos carry `data-tome-reveal-item`. - **Public surface.** `localClassPathwayBlock`, `LocalClassPathway`, the `LocalClassPathwayData` types. - **New columns.** The block adds tables and columns, so a site with migrations generates one. **Fix: the level stripe no longer flashes empty at load.** A stripe already in view when the island hydrates used to be drawn by the server, blanked for about 41ms when the island armed it, then sprung back in. The shared `armStripeFill` now leaves a stripe that is in view at hydration drawn and never arms it; only a stripe below the fold (or in a band not yet shown) arms and fills when it enters view. This applies to the finder's `ask` and `level` variants and to the pathway; reduced motion is unchanged (always drawn). **Per-context wording and theme tokens (all opt-in; a stored block without them renders byte for byte as before).** - **`ageBands[].pillLabel`, `selectLabel`, `saidLabel`** on the finder's own bands: the pill ("5–7"), the timetable's filter option ("Ages 5–7") and the said-back line's `{age}` ("5 to 7"). Each falls back to `label`. `local-class-schedule` gains `ageBands[].selectLabel` for its filter option. With `source: 'collection'` the shared timetable's bands carry them too (`@wabbit/tome-local` stores and normalizes the three fields): the document wins, and a band without them falls back to its `label`. `trackLabels[]` stays a block field. - **`liveLine.clauseTemplate`** on the finder: wording for the term clause with `{term}`, `{start}`, `{end}` and `{note}`, e.g. "{term} runs {start} to {end}, {note}". A token with no value drops with the separator beside it, and a template that needs dates on a term without them falls back to the default clause. - **`trackLabels[]`** on the finder (`track`, `levelOf`): the level position wording per track, so preschool reads "Step 1 of 2" while gymnastics keeps "Level 1 of 5". A track not listed uses `labels.levelOf`. A track with one level still shows no stripe. - **`timeStyle`** was already stored on the finder (`compact` drops AM/PM, "Tue 4:30"); it needs only to be set. - **Theme tokens.** The level stripe's segments read `--tome-level-stripe-w`, `--tome-level-stripe-h` and `--tome-level-stripe-gap` (finder and schedule), and the trial button reads `--tome-finder-cta-min-h` (never below the 44px target). Each has the old rem value as its fallback, so a theme's px sizes win over the root-relative ones. - **New columns.** The new fields add columns to the block's tables, so a site with migrations generates one. **A rolling list, the next-sessions card list, and the phone timetable (all opt-in; a stored block without the new fields renders byte for byte as before).** - **`range: 'next'` with `limit`** on the `list` variant shows the next dated sessions (default 4) across this week and next: one section per date, each row dated, no day strip, breaks skipped and the term's end respected, narrowed by `preset.level` or `preset.age`. The root carries `data-schedule-range="next"` and each day section `data-date`. `calendarLink` on it downloads every slot of the filtered classes. The two new fields add `range` and `limit` columns to the block's tables, so a site with migrations generates one. - **`LocalNextSessions`**, a server component (not a block), lists a level's next sessions for a program page's next-step card: `data-next-sessions`, `data-next-session`, `data-next-session-date`, `data-next-session-time`, `data-schedule-next`, `data-schedule-spots`. `./server` adds `LocalNextSessionsFromSchedule`, which reads the document and the profile's closed dates. Both read tome-local's `nextSessions`, so the card, the finder and the timetable agree on what is next. - **The phone timetable.** The day strip now sticks to the whole block (the controls box steps aside below 768px) on a ground set by `--tome-schedule-strip-bg`; a tab that shows its date is at least 55 by 62px (`--tome-schedule-tab-min-w`, `--tome-schedule-tab-min-h`); `labels.noClassesBand` can use `{ageLower}` ("No classes for ages 5–7 on Fridays"); a finished class (with `labels.finished` set) keeps an outline pill; printing shows the whole week whatever the screen, and the controls and strip are not printed. - **Fix: the age filter overflowed below 374px.** `.filter` and `.select` now shrink (`min-width: 0`, `max-width: 100%`) and clip a long option such as "Under 3, with a grown-up" with an ellipsis, so nothing overflows at 320px. **New block: `local-credentials`.** What a business can prove, in two variants. `strip` is a row of short checkable lines; `panel` is a rounded chapter with ratios as large tabular numerals, up to two policy lists, a credentials ledger (kind, issuer, what it covers, valid, verify) and a "policy last reviewed" line. - **Reads the business profile.** `dataSource: 'profile'` (the default) takes the credentials from `@wabbit/tome-local`'s profile through a virtual `profile` field and the same read hook as the other profile-aware blocks; the block's own `credentials[]` fill in when the profile has none, and `dataSource: 'inline'` uses them alone. A credential past its `expiresOn` date never renders. The profile slice now carries each credential's `validFrom`, `expiresOn`, `verifyUrl` and `sample`; the keys already there are unchanged. - **Strip lines can be bound to a credential.** A line with a `kind` shows only while an unexpired credential of that kind exists; a line without one is your own statement. - **Samples are labeled.** A credential ticked Sample in the profile (tome-local's P-13 checkbox) or in the block's rows shows the label ("Sample" by default, `sampleLabel`) in the ledger, and a strip line reads Sample when it is ticked itself or when every credential of its kind is a sample. - **Accessible by construction.** A real table with a caption, column and row headers, a "Verify {credential}" link per row, decorative check marks beside text, and a ledger that stacks below 768px with each value led by its column name. No script, no motion, no colored rules. New public surface: `localCredentialsBlock`, `LocalCredentials`, the `LocalCredentialsData` types and `LocalBlockCredential`. A stored page without the new block renders as before. **New variant: `local-price-list` `term-fee`.** Fee cards for a business that sells a free trial and then a term. The cards share one grid and one height with their buttons on one line; the recommended card (`emphasis: 'primary'`) is marked by its surface, padding, weight and the only filled button, never by a colored rule. A card with two or more priced variants gets a classes-a-week picker (a radiogroup) that swaps the price and its "per" line; without script the choices read as a plain list. Prices and the "per" text use tabular figures, and whole amounts print without cents. - **New fields, all optional and shown only for this variant:** on the list `kicker`, `heading`, `intro`, `pickerLabel`, `includesLabel`, `excludesLabel`, `objections[]` and `note`; on each item `emphasis`, `kicker`, `includes`, `excludes`, `includesLabel`, `excludesLabel` and `action { label, href }`; on each price `per`. The other three variants never read them, and their stored renders are byte for byte as before (the pack's golden covers `card-grid`, `ledger` and `teaser`). - **Hooks:** `data-fee-card` (`data-fee-emphasis`), `data-fee-kicker`, `data-fee-name`, `data-fee-price`, `data-fee-per`, `data-fee-picker`, `data-fee-option`, `data-fee-ladder`, `data-fee-includes`, `data-fee-excludes`, `data-fee-action`, `data-fee-objections`, `data-fee-objection`, `data-fee-note`. - **New columns.** The new fields add columns to the block's tables, so a site with migrations generates one.
  • e192f5e: `local-class-schedule` gains three opt-in display options for a compact, dated timetable: a split time, short day names and a "today" word. - **`timeSplit`** (checkbox, off by default). Marks each class time up as `[data-schedule-time-start]` and `[data-schedule-time-end]`, the end carrying the dash with no spaces ("4:30" and "–5:15"), so a theme can stack or restyle the halves. The text reads the same and the screen-reader names still use the whole time. Pairs with `timeStyle: 'compact'`. - **`dayNameStyle`** (`long` default, or `short`). With `short` each day heading shows the short weekday ("Mon 19", in the locale's own short form) and keeps the full name for screen readers (`[data-schedule-day-short]`, `[data-schedule-day-long]`). - **`labels.today`** (text, empty by default). Set it and today's day heading carries that word (`[data-schedule-today-label]`, `hidden` on every other day, kept current after mount for a cached page) and today's phone tab takes it into its accessible name ("Tuesday, October 20, Today"). Placeholder `Today`. A block that sets none of them renders exactly as before.
  • 6534727: `local-class-schedule` gains four opt-in options for a short rolling list, and `LocalNextSessions` gains two format options. - **`timeMinutes`** (`auto` default, or `always`). With `timeStyle: 'compact'`, `always` keeps the minutes on the hour ("9:00" rather than "9"). The locale clock is unchanged. - **`showControls`** (checkbox, on by default). Off leaves out the age and level filters, the filter summary, the print button and the phone day strip. - **`showTerm`** (checkbox, on by default). Off leaves out the term line and the break notes. - **`calendarStyle`** (`link` default, or `icon`). `icon` draws each row's calendar link as an icon button (`data-ics-style="icon"`) with an `aria-hidden` SVG and an accessible name: the `labels.calendarRow` text when set, else the link word and the class, day and time. - **`LocalNextSessions`**: `timeMinutes` (`auto` or `always`, as above) and `dateOrder` (`weekday-month-day` default, "Tue, Oct 20"; or `weekday-day-month`, "Tue 20 Oct"). The per-row age, level and level-stripe repeat is already hidden with `show.age`, `show.level` and `show.levelStripe`. A block or card that sets none of the new options renders the markup it did before; the new select fields add columns, so a site with migrations generates one.
  • 4320bd2: `local-class-pathway` gains a level teaser, a card blurb, an age tag and a ladder link; `local-class-schedule` gains a next-term cell; `local-class-finder` gains a `{when}` token in its spots lines. Every addition is optional and off or empty by default, and a block that sets none of them renders the markup it did before: - **Pathway, per level** (the shared timetable's level, or the block's inline `levels`): `teaser` replaces "Who it's for" on a next-level card (`data-pathway-teaser-text`), in the `bands` next-band card and the `ladder` card. `blurb` replaces the "Who it's for" and "Moves up when" pair on a `cards` card (`data-pathway-blurb`), and `ageTag` adds a tag beside its name (`data-pathway-age-tag`), the name, tag and stripe sharing one `data-pathway-card-head` row. - **Pathway, ladder:** `teaserLink { label, href }` sets the next-level card's link. `href` replaces the level's program page and `label` (`{class}` fills in) replaces the `labels.aboutLevel` copy. - **Schedule:** `nextTerm { label, line }` adds a cell after the term and the breaks, for example "Winter term" and "Times announced Dec 1" (`data-schedule-next-term`, with `-label` and `-line` parts). It works with inline data and with a shared timetable. - **Finder:** the spots, waitlist and full lines fill `{when}`. It reads `labels.whenToday` (default "today's {time} class") when the class is today and `labels.whenDay` (default "{day}") on any other day, so "{n} spots left in {when}" says "3 spots left in today's 4:30 class". The default lines still say "on {day}". The chosen fee option's keyboard focus ring now uses the theme's ring color (`--tome-color-ring`) instead of the button's text color, which was the page ground on the chosen option and left no visible ring. The new level and block fields add columns, so a site with migrations generates one. The `@wabbit/tome-local` peer floor is unchanged at `>=0.3.0`: with an older tome-local the level fields are simply never filled in the shared timetable.
v0.5.1patch

40f5b45: Accessibility fix: finished classes and empty days in `local-class-schedule` keep AA text contrast instead of fading below it. A finished row was dimmed with `opacity: 0.55`, and an empty day tab with `opacity: 0.5`, which took their text below 4.5:1. A finished row now paints its time and name in `--tome-color-on-surface-muted` at full strength, strikes the time and dashes its border; an empty day tab keeps the muted color and reads as empty through a lighter weight and a strike. Themes that targeted `[data-finished]` or `[data-empty]` keep the same hooks.

  • 40f5b45: Accessibility fix: finished classes and empty days in `local-class-schedule` keep AA text contrast instead of fading below it. A finished row was dimmed with `opacity: 0.55`, and an empty day tab with `opacity: 0.5`, which took their text below 4.5:1. A finished row now paints its time and name in `--tome-color-on-surface-muted` at full strength, strikes the time and dashes its border; an empty day tab keeps the muted color and reads as empty through a lighter weight and a strike. Themes that targeted `[data-finished]` or `[data-empty]` keep the same hooks.
v0.5.0minor

2103592: Adds Local Class Schedule (`local-class-schedule`), a timetable block for gyms, studios, schools and camps, with spots lines that expire, enroll links and per-class calendar downloads. `week` and `list` layouts; phones get a sticky day strip and an age filter. - **Classes as data.** Each row holds a name, level, age range, one or more days, start and end times, a teacher and a room, and an enroll link (a default for all, or one per class; `{class}`, `{day}` and `{start}` fill in from the row). Links open in a new tab through the link adapter. - **Spots that cannot go stale.** A hand-entered "spots left", "Waitlist open" or "Class full" status needs an expiry. An expired or incomplete entry renders nothing, on the server and, after mount, on a cached page. - **Timezone, term and breaks.** The timezone falls back to the business profile's. A term masthead and breaks print above the timetable; the profile's closed dates are added as breaks unless turned off. - **Calendar files.** Each class downloads a weekly `.ics` (a `data:` link, no route) that ends with the term and skips the breaks. - **Phones.** Below 768px a small client island turns the day strip into an accessible tablist and adds age and level filters. Without script every day shows. - **Hooks.** Neutral `data-schedule-*` attributes and theme-neutral default CSS; the existing blocks are unchanged.

  • 2103592: Adds Local Class Schedule (`local-class-schedule`), a timetable block for gyms, studios, schools and camps, with spots lines that expire, enroll links and per-class calendar downloads. `week` and `list` layouts; phones get a sticky day strip and an age filter. - **Classes as data.** Each row holds a name, level, age range, one or more days, start and end times, a teacher and a room, and an enroll link (a default for all, or one per class; `{class}`, `{day}` and `{start}` fill in from the row). Links open in a new tab through the link adapter. - **Spots that cannot go stale.** A hand-entered "spots left", "Waitlist open" or "Class full" status needs an expiry. An expired or incomplete entry renders nothing, on the server and, after mount, on a cached page. - **Timezone, term and breaks.** The timezone falls back to the business profile's. A term masthead and breaks print above the timetable; the profile's closed dates are added as breaks unless turned off. - **Calendar files.** Each class downloads a weekly `.ics` (a `data:` link, no route) that ends with the term and skips the breaks. - **Phones.** Below 768px a small client island turns the day strip into an accessible tablist and adds age and level filters. Without script every day shows. - **Hooks.** Neutral `data-schedule-*` attributes and theme-neutral default CSS; the existing blocks are unchanged.
v0.4.0minor

a2c0a70: Local Live Status and Local Logistics now read the site's business profile, so an owner sets hours, phone and address once in the admin and every block (and the Now Bar) follows. **BREAKING:** the `@wabbit/tome-local` peer floor rises to `>=0.2.0 <1.0.0`, the release that ships the business profile. Local Live Status also stores its SQL rows under `<collection>_lls`: its slug-derived table and enum names passed Postgres's 63-character identifier limit, so no SQL site could build a schema with it before, and no stored data moves. MongoDB sites are unaffected. - **Profile first, inline fallback.** Both blocks gain `dataSource`: `profile` (the default, also for blocks saved before this release) uses the profile's hours, phone, address and coordinates, visit notes, service areas and credentials wherever it has them and the block's own fields for the rest; `inline` uses the block's fields only (a second venue, a fixed example). A block on a site without a profile renders exactly as before. No content migration. - **How:** a virtual, hidden `profile` field on each block reads the profile when Payload returns the block (one read per request, inside the request's transaction), so the renderers stay pure and nothing new is stored. A failed read falls back to the inline fields with a warning. - **Call links** use the profile's E.164 number. A hidden profile address shows the locality only and no directions link. - **Local Live Status availability mode** now renders: the first answer of the live-status chain (a booking engine when installed, the profile's expiring status message, then its rating citation), with `data-live-state="availability"` and `data-live-tone`. `auto` falls back to it when there are no hours. An expired line is not shown, also on a cached page. - **Local Logistics** shows the profile's service areas as chips and its current credentials (`data-logistics-areas`, `data-logistics-credentials`, toggled by `show.serviceAreas` / `show.credentials`), can show a named profile schedule (`schedule`), and titles the profile's visit notes with `visitNoteTitles`. - **New `./server` entry:** `LocalLiveStatusAnnouncementFromProfile` puts the Now Bar in chrome's `announcementSlot` with no hours in code; `getLiveStatusAnnouncementProps(payload, props)` returns the props with the profile added. `LocalLiveStatusAnnouncement` is unchanged. - **Root exports:** `profileFields`, `makeProfileReadHook`, `loadProfileForRequest`, `buildBlockProfile`, `activeProfile`, `applyProfileToLiveStatus`, `applyProfileToLogistics` and the `LocalBlockProfile` / `LocalBlockDataSource` types.

  • a2c0a70: Local Live Status and Local Logistics now read the site's business profile, so an owner sets hours, phone and address once in the admin and every block (and the Now Bar) follows. **BREAKING:** the `@wabbit/tome-local` peer floor rises to `>=0.2.0 <1.0.0`, the release that ships the business profile. Local Live Status also stores its SQL rows under `<collection>_lls`: its slug-derived table and enum names passed Postgres's 63-character identifier limit, so no SQL site could build a schema with it before, and no stored data moves. MongoDB sites are unaffected. - **Profile first, inline fallback.** Both blocks gain `dataSource`: `profile` (the default, also for blocks saved before this release) uses the profile's hours, phone, address and coordinates, visit notes, service areas and credentials wherever it has them and the block's own fields for the rest; `inline` uses the block's fields only (a second venue, a fixed example). A block on a site without a profile renders exactly as before. No content migration. - **How:** a virtual, hidden `profile` field on each block reads the profile when Payload returns the block (one read per request, inside the request's transaction), so the renderers stay pure and nothing new is stored. A failed read falls back to the inline fields with a warning. - **Call links** use the profile's E.164 number. A hidden profile address shows the locality only and no directions link. - **Local Live Status availability mode** now renders: the first answer of the live-status chain (a booking engine when installed, the profile's expiring status message, then its rating citation), with `data-live-state="availability"` and `data-live-tone`. `auto` falls back to it when there are no hours. An expired line is not shown, also on a cached page. - **Local Logistics** shows the profile's service areas as chips and its current credentials (`data-logistics-areas`, `data-logistics-credentials`, toggled by `show.serviceAreas` / `show.credentials`), can show a named profile schedule (`schedule`), and titles the profile's visit notes with `visitNoteTitles`. - **New `./server` entry:** `LocalLiveStatusAnnouncementFromProfile` puts the Now Bar in chrome's `announcementSlot` with no hours in code; `getLiveStatusAnnouncementProps(payload, props)` returns the props with the profile added. `LocalLiveStatusAnnouncement` is unchanged. - **Root exports:** `profileFields`, `makeProfileReadHook`, `loadProfileForRequest`, `buildBlockProfile`, `activeProfile`, `applyProfileToLiveStatus`, `applyProfileToLogistics` and the `LocalBlockProfile` / `LocalBlockDataSource` types.
  • 2e9f1ab: Phone links in the contact, high-impact-hero and local-logistics code now use the shared core `tel:` helper, so a site can qualify national numbers to E.164. The exported `toTelHref` helpers (and `resolveContactValueLink`, `resolveInlineCall`) accept an optional `{ defaultCallingCode }`. The high-impact-hero reads `defaultCallingCode` from the render context passed to `RenderBlocks({ context })`; without it, hrefs stay digits-only as before. Requires `@wabbit/tome-core` 1.22.0 or later as a peer.
  • 313cc47: Fix: Local Price List now builds its SQL schema on Postgres and SQLite when the collection that holds it has drafts on. The featured lead's heading-level select produced the enum `enum__pages_v_blocks_local_price_list_featured_lead_heading_level` (65 characters) on a drafts-enabled `pages` collection, past the 63-character identifier limit, so the schema failed to build. The field now sets `enumName` to `enum_<table>_hl`, where `<table>` is the enclosing table (main or version): - `enum_pages_blocks_local_price_list_featured_lead_heading_level` becomes `enum_pages_blocks_local_price_list_featured_lead_hl` - `enum__pages_v_blocks_local_price_list_featured_lead_heading_level` becomes `enum__pages_v_blocks_local_price_list_featured_lead_hl` No other identifier in the pack passes the limit for a drafts-enabled `pages` collection; two version-table enums sit exactly at 63 (`…_sections_items_price_mode`, `…_teaser_actions_appearance`), so a longer collection name with drafts on still needs its own `dbName`. **Migration:** a SQL site WITHOUT drafts that already stored this block has the old enum `enum_<collection>_blocks_local_price_list_featured_lead_heading_level`. Generate and run a migration after upgrading (`payload migrate:create`, then `payload migrate`); it renames the enum type and the stored values are unchanged. Sites with drafts on could not store the block before, and MongoDB sites are unaffected.
v0.3.1patch

35207ff: The Now Bar (`local-live-status`, variant `bar`) now lays its row out on chrome's shared container contract, so its status and links land on the same edges as the header, the footer and the page content. - The row reads `--tome-chrome-content-max` (its max width) and `--tome-chrome-content-inline` (its inline padding), the tokens every navbar and footer already reads. The bar lives in chrome's announcement slot, a chrome surface, so it follows the same box. - On the Table theme, which sets the tokens to `100%` and `clamp(1rem, 4vw, 3rem)`, the row now runs from the page grid's outer lane to the other: at a 1857px window the status starts at 54px and the links end 54px from the right, in line with the wordmark, the menu rail and the footer. Before this it sat in a centered 1280px box (status at 281px). - A site whose theme sets neither token renders exactly as before: the row falls back to `--tome-container` (else `min(100% - 2rem, 1280px)`) with no inline padding. Phones keep the 124px block.

  • 35207ff: The Now Bar (`local-live-status`, variant `bar`) now lays its row out on chrome's shared container contract, so its status and links land on the same edges as the header, the footer and the page content. - The row reads `--tome-chrome-content-max` (its max width) and `--tome-chrome-content-inline` (its inline padding), the tokens every navbar and footer already reads. The bar lives in chrome's announcement slot, a chrome surface, so it follows the same box. - On the Table theme, which sets the tokens to `100%` and `clamp(1rem, 4vw, 3rem)`, the row now runs from the page grid's outer lane to the other: at a 1857px window the status starts at 54px and the links end 54px from the right, in line with the wordmark, the menu rail and the footer. Before this it sat in a centered 1280px box (status at 281px). - A site whose theme sets neither token renders exactly as before: the row falls back to `--tome-container` (else `min(100% - 2rem, 1280px)`) with no inline padding. Phones keep the 124px block.
v0.3.0minor

37d3a0c: `local-price-list` sections can set their own `nutritionInMeta` and `metaOrder`, overriding the list's for that section's items. - A drinks section can read "Oat +0.50 · 120 kcal" (`metaOrder: 'modifiers-first'`) while the dishes keep leading with protein. Both fields are optional with no default; left empty, a section uses the list's setting, so existing lists render exactly as before. - Two optional fields are added to each `sections[]` row (`nutritionInMeta`, a number from 0 to 6, and `metaOrder`, a select). On a Postgres site, generate and run a migration for the new columns. - The view model resolves the settings per section and per item (`SectionModel.nutritionInMeta` / `.metaOrder`, `ItemModel.nutritionInMeta` / `.metaOrder`); the list-level `PriceListModel` values are unchanged.

  • 37d3a0c: `local-price-list` sections can set their own `nutritionInMeta` and `metaOrder`, overriding the list's for that section's items. - A drinks section can read "Oat +0.50 · 120 kcal" (`metaOrder: 'modifiers-first'`) while the dishes keep leading with protein. Both fields are optional with no default; left empty, a section uses the list's setting, so existing lists render exactly as before. - Two optional fields are added to each `sections[]` row (`nutritionInMeta`, a number from 0 to 6, and `metaOrder`, a select). On a Postgres site, generate and run a migration for the new columns. - The view model resolves the settings per section and per item (`SectionModel.nutritionInMeta` / `.metaOrder`, `ItemModel.nutritionInMeta` / `.metaOrder`); the list-level `PriceListModel` values are unchanged.
  • f4a08ca: **BREAKING:** these packs now need `@wabbit/tome-blocks-core` 0.35.0 or later, because they read the wrapper's new `data-block-placement` instead of its inline style text. **Migration:** upgrade `@wabbit/tome-blocks-core` to 0.35.0 or later alongside these packs. A site that renders its own block wrapper (with `data-tome-block-wrapper`) on a span other than `1 / -1` should add `data-block-placement={blockPlacementFor(span)}` to it; a wrapper without the attribute is read as full width. - `@wabbit/tome-blocks-local-pack`: in Safari, the price list's teaser list and the logistics block's content touched the viewport edges once the page had scrolled. Their side padding depended on matching the wrapper's `style` text, which WebKit rewrites as `grid-column-start`/`grid-column-end` longhands as soon as the scroll reveal writes to it. The padding rule now reads `data-block-placement`. Chromium and Firefox render as before. - `@wabbit/tome-blocks-house`: `LaneGrid` decides whether to subgrid its block wrapper (full row, content column, or its own tracks for any other span) from `data-block-placement`, so the decision no longer changes in Safari after the scroll reveal runs. - `@wabbit/tome-blocks-marketing-starter`: `exhibit`'s nested block wrappers carry `data-block-placement`, matching `RenderBlocks`.
v0.2.0minor

f771652: Local Price List: options the Table demo needed, each defaulting to the existing output. - `featuredLead.description`: the featured lead's own text; empty keeps the item's description (the card's short one). - `featuredLead.headingLevel` (`auto` | `h1` | `h2`, default `auto`): `auto` stays the page `h1` on `card-grid`, and on `ledger` is now an `h1` when the block is the page's first (`index` 0) and an `h2` otherwise; `h1` gives a menu page that opens with a ledger its one `h1` when the host does not pass `index`. - `breakerAction { label, href }`: the grid-breaking item's own link (for example "Reserve the tasting menu"); empty falls back to `primaryAction`, as before. - `metaOrder` (`nutrition-first` | `modifiers-first`, default `nutrition-first`): `modifiers-first` prints "Oat +0.50 · 120 kcal". - Ledger rows now carry the card's action: with ordering off and `askLabel` set, a row with details gets the "ask" control and its details panel opens in place under the row (shown in place without JavaScript); the breaker's row carries the breaker's action. Ordering on is unchanged (the add button).

  • f771652: Local Price List: options the Table demo needed, each defaulting to the existing output. - `featuredLead.description`: the featured lead's own text; empty keeps the item's description (the card's short one). - `featuredLead.headingLevel` (`auto` | `h1` | `h2`, default `auto`): `auto` stays the page `h1` on `card-grid`, and on `ledger` is now an `h1` when the block is the page's first (`index` 0) and an `h2` otherwise; `h1` gives a menu page that opens with a ledger its one `h1` when the host does not pass `index`. - `breakerAction { label, href }`: the grid-breaking item's own link (for example "Reserve the tasting menu"); empty falls back to `primaryAction`, as before. - `metaOrder` (`nutrition-first` | `modifiers-first`, default `nutrition-first`): `modifiers-first` prints "Oat +0.50 · 120 kcal". - Ledger rows now carry the card's action: with ordering off and `askLabel` set, a row with details gets the "ask" control and its details panel opens in place under the row (shown in place without JavaScript); the breaker's row carries the breaker's action. Ordering on is unchanged (the add button).
  • 19651f2: Local Price List details: Esc now closes an open panel wherever focus is. The handler listened on the list root, so it fired only with focus inside the panel or on its control, and Safari does not focus a button on click. A document listener now runs while any panel in the list is open (and only then); Esc closes the panel holding focus, else the one whose control has focus, else the one opened last, and returns focus to the control that opened it (unless the visitor has moved focus elsewhere). Esc inside an open modal dialog elsewhere (the order drawer) is left to the dialog.
  • ed17bbf: Local Logistics `split` lays out on the same grid as the price list's bands. Its root was a subgrid of the page grid, which has no gap, so the map and the details touched with no gutter and its heading sat a few px left of the other bands' reading-start (theirs is a 16-column grid with gutters). The root is now its own grid with `--tome-grid-gap` gutters (4, 8, then 16 columns with the marginalia and reading lane names), padded to the content column like the teaser; the inner subgrid sets no gap of its own.
  • 9be2e3d: Local Live Status `bar`: the detail never truncates. From 640px the band was one row with the status text clipped by an ellipsis, so at tablet widths the detail read "opens toda…". The row now wraps: when the status and the links do not fit side by side, the links move under the status (the status's flex basis is its one-line width, so the links wrap before the text is squeezed), and a status longer than the row wraps its own text. A band that fits stays one 44px row; phones are unchanged.
  • 8b01953: Ordering island styles now beat tome-ui's element resets. Every rule in `ordering.tome-css` sat inside `:where()` (zero specificity), so `a { color }` and `button, select { background: none; border: none }` won: Checkout's label went ink on ink, the floating pill lost its fill and shape, and the pickup select lost its edge. Each rule now weighs exactly one hook attribute (0,1,0), qualifiers inside `:where()`, so a theme's `[data-tome-theme] [data-order-*]` (0,2,0) still wins; Checkout's `:hover` ink answers tome-ui's `a:hover`. The price list's visually hidden currency name ("17.00 US dollars") now uses the full visually-hidden recipe (`margin: -1px`, zero padding and border), so it no longer overflows 1px in WebKit.
  • b3bd825: Local Price List: on phones the featured stamp keeps its top-left corner over the photo and only shrinks to 92px. The phone rule had moved it to the top-right (a generic placement); the featured figure's stamp sits top-left at every width.
v0.1.0minor

daf006a: New package: `@wabbit/tome-blocks-local-pack`, the block pack for local businesses, built on `@wabbit/tome-local`'s open-state utility and free with any theme. - Entries: `register()` at the root, plus `./render`, `./render/register`, `./meta`, `./demo` and `./ordering`. The registries are in place and the `local-pack` bundle registers with tier `free`. - The pack exports the hours types (`OpenStateSource`, `LocalWeeklyHours`, `LocalHoursOverride` and others) from `@wabbit/tome-local/hours`, so its blocks and your data share one shape. - Requires `@wabbit/tome-blocks-core` 0.31.0 or later and `@wabbit/tome-local` 0.1.1 or later. `@wabbit/tome-ui` and `@wabbit/tome-blocks-house` are optional peers.

  • daf006a: New package: `@wabbit/tome-blocks-local-pack`, the block pack for local businesses, built on `@wabbit/tome-local`'s open-state utility and free with any theme. - Entries: `register()` at the root, plus `./render`, `./render/register`, `./meta`, `./demo` and `./ordering`. The registries are in place and the `local-pack` bundle registers with tier `free`. - The pack exports the hours types (`OpenStateSource`, `LocalWeeklyHours`, `LocalHoursOverride` and others) from `@wabbit/tome-local/hours`, so its blocks and your data share one shape. - Requires `@wabbit/tome-blocks-core` 0.31.0 or later and `@wabbit/tome-local` 0.1.1 or later. `@wabbit/tome-ui` and `@wabbit/tome-blocks-house` are optional peers.
  • 74120f1: New blocks: Local Live Status (`local-live-status`), a live open-now line or bar, and Local Logistics (`local-logistics`), a find-us band with keyless directions and today-highlighted hours. - **Local Live Status** (`bar`, `line`) words your opening hours as a lead and a detail: "Open now · kitchen closes at 10", "Closed now · opens tomorrow at 11", "Closed today · holiday hours: back Wednesday at 5:30". It handles split shifts, hours past midnight, holidays and special hours, and a named secondary schedule, all in the business timezone. You set one action per state and add a call link and a keyless directions link. `timeStyle: 'compact'` drops AM/PM. The server renders the status. The browser then recomputes it after load, at the next opening or closing, and when the tab returns, and it announces only real changes. It renders nothing without hours. - **`LocalLiveStatusAnnouncement`** renders the bar outside the block registry, for example in the chrome header's announcement slot. - **Local Logistics** (`split`) shows the address with a Google or Apple directions link (no API key), a call link, and an optional map image that is always labeled as illustrative. It groups identical days in the hours and marks today in the business timezone, server-side and then refreshed in the browser. Today's holiday or special hours appear as their own row, and visit notes such as parking and access can follow.
  • 150cd41: The `./ordering` entry now ships the ordering island: `OrderProvider`, `OrderButton` (for chrome's `accountSlot`), `OrderPill`, `OrderDrawer` (a native modal `<dialog>`) and `useOrder()`, plus the `toOrderItems`, `buildCheckoutUrl` and `createPriceFormatter` helpers. - It keeps the order in the browser only, in `sessionStorage` under `tome-local-order:<listKey>`. If storage is unavailable, the island renders empty. - It hydrates a price list's `data-order-add` and `data-order-stepper` markup and keeps every element that shares an item key in sync. It also opens the drawer from any `[data-order-open]` control and sets `<html data-order-state="empty | has-items | open">`. - Checkout is a link to `checkoutUrl` with `item=<key>:<qty>` params. It never posts a cart or takes payment. A `demoLabel` renders beside it with `data-compliance-label`.
  • 86c367b: Adds the `local-price-list` block: a menu or price list as a photo card grid (default), a printed ledger or a home-page teaser, from one data shape. - `card-grid`: a featured lead with a round stamp, a numbered category rail (sticky, with an optional scroll-spy), a masthead per category, photo cards with details that open inside the card, and one item that breaks the grid with its details always open. - `ledger`: ruled rows, attribute codes and right-aligned tabular prices, every size or measure listed. `teaser`: a photo panel beside a numbered list whose rows swap the photo on hover or focus, with a thumbnail per row on phones. - Prices are integer minor units formatted with `Intl.NumberFormat`; by default they print without a symbol and a visually hidden currency name follows. Price modes: fixed, from, range, market price, on request. - Ordering is off by default. With `ordering.enabled` and a `checkoutUrl`, each priced item gets an add button and a stepper for the pack's ordering island; otherwise no add action renders. - `tags` and `attributes` are free text unless option lists are passed to the block factory (`LOCAL_TAG_PRESETS`, `LOCAL_ATTRIBUTE_PRESETS`). Without JavaScript the rail is a list of anchors and every details panel shows in place.

Blocks Portfolio Pack

v0.2.1
v0.2.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.2.0minor

58c38a6: `plate-sequence` gains an opt-in static before and after pair with a record. `plateSequenceBlock.block({ pairRecord: true })` adds the inline pair kind `before-after-record` and a `pairRecord` group (consent recorded and the date it was recorded, treatment, the date the photos were taken, and a pair-level disclaimer). A pair of this kind renders only when consent is ticked with its date, a treatment and a disclaimer are all recorded, and it holds exactly two images; otherwise the whole plate is left out and the plates that do show number without a gap. It prints the treatment, the date and the disclaimer in the caption (`data-plate-pair-record`, `-treatment`, `-date`, `-disclaimer`) and never prints the consent itself. It is still: no drag divider, slider or script. The stock `before-after` kind renders exactly as before and is not gated. Inline items only: the Work collection's story has no record fields. Schema: off by default, so the block's fields are unchanged, held by the existing case-blocks golden and a test. Turning `pairRecord` on adds a select option and group columns, so SQL sites need a migration at that point.

  • 58c38a6: `plate-sequence` gains an opt-in static before and after pair with a record. `plateSequenceBlock.block({ pairRecord: true })` adds the inline pair kind `before-after-record` and a `pairRecord` group (consent recorded and the date it was recorded, treatment, the date the photos were taken, and a pair-level disclaimer). A pair of this kind renders only when consent is ticked with its date, a treatment and a disclaimer are all recorded, and it holds exactly two images; otherwise the whole plate is left out and the plates that do show number without a gap. It prints the treatment, the date and the disclaimer in the caption (`data-plate-pair-record`, `-treatment`, `-date`, `-disclaimer`) and never prints the consent itself. It is still: no drag divider, slider or script. The stock `before-after` kind renders exactly as before and is not gated. Inline items only: the Work collection's story has no record fields. Schema: off by default, so the block's fields are unchanged, held by the existing case-blocks golden and a test. Turning `pairRecord` on adds a select option and group columns, so SQL sites need a migration at that point.
v0.1.5patch

acdab21: The work index no longer pulls server code into client bundles, so webpack builds succeed. The pack read its settings defaults from the portfolio layer's root entry, which webpack cannot tree-shake out of a client bundle. It now reads them from the layer's client-safe `/settings` entry. The `@wabbit/tome-portfolio` peer floor rises to 0.2.0, the first version with `/settings`.

  • acdab21: The work index no longer pulls server code into client bundles, so webpack builds succeed. The pack read its settings defaults from the portfolio layer's root entry, which webpack cannot tree-shake out of a client bundle. It now reads them from the layer's client-safe `/settings` entry. The `@wabbit/tome-portfolio` peer floor rises to 0.2.0, the first version with `/settings`.
v0.1.4patch

a81511e: The `project-hero` and `work-index` `next` demos set the base path to `#`, so their crumbs and back link no longer point at the layer default `/work`, a path the gallery host may not have. Real rendering is unchanged.

  • a81511e: The `project-hero` and `work-index` `next` demos set the base path to `#`, so their crumbs and back link no longer point at the layer default `/work`, a path the gallery host may not have. Real rendering is unchanged.
v0.1.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.2patch

8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The plate-sequence plate `aspect` (labeled Shape) was a select whose values (`4:3`, `16:9`, ...) contain a colon, which made Payload's GraphQL schema build throw and `/api/graphql` return 500 on any site using the block. It is now a text field that accepts exactly `4:3`, `4:5`, `3:2`, `16:9`, `21:9`, `16:10` or empty; existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it.

  • 8f11b80: Aspect fields are now validated text so GraphQL builds; stored values unchanged. The plate-sequence plate `aspect` (labeled Shape) was a select whose values (`4:3`, `16:9`, ...) contain a colon, which made Payload's GraphQL schema build throw and `/api/graphql` return 500 on any site using the block. It is now a text field that accepts exactly `4:3`, `4:5`, `3:2`, `16:9`, `21:9`, `16:10` or empty; existing content reads back unchanged and no migration is needed. In the admin the dropdown becomes a text input with the allowed values listed under it.
v0.1.1patch

5ae76ee: The drawing viewer on phones is a true full-screen dialog and opens the whole drawing fitted to the screen. The plate-sequence drawing viewer is a true full-screen dialog on phones (`100dvh`, safe-area padding, its own backdrop) and opens the whole drawing fitted to the screen instead of at its native width; pinch-zoom and pan are the browser's own. The 44px Close, Escape, focus return and the clone-not-reparse copy are unchanged. The canvas no longer carries an inline pixel width.

  • 5ae76ee: The drawing viewer on phones is a true full-screen dialog and opens the whole drawing fitted to the screen. The plate-sequence drawing viewer is a true full-screen dialog on phones (`100dvh`, safe-area padding, its own backdrop) and opens the whole drawing fitted to the screen instead of at its native width; pinch-zoom and pan are the browser's own. The 44px Close, Escape, focus return and the clone-not-reparse copy are unchanged. The canvas no longer carries an inline pixel width.
v0.1.0minor

ab6974a: New package: `@wabbit/tome-blocks-portfolio-pack`, portfolio blocks for a practice that works project by project, written for the Studio theme and reading the `@wabbit/tome-portfolio` project records. - Block `work-index` with four layouts (`feature`, `index`, `selected` and `next`). `feature` is a split hero for one project (the plate, the job number, title, two sentences, the plate count and one button, with the nine-cell Title Block under both); with no project chosen it shows the one flagged Featured. `index` is a ledger of every project filed by job number, with a thumbnail column, type and status chips with live conditional counts, a count line, the featured project quieted as "Featured above" and a thumbnail credits line. Both render on the server and work without script. - The source trio (`source`, the project relationship, the virtual `portfolioData` field) and `makePortfolioReadHook`: one read per request, shared through `req.context`, falling back to the block's own fields when the read fails. - Shared `TitleBlockCells` (the nine cells in a fixed order), the accent marks, and the per-request morph-name registry for the case page. Listing surfaces carry `data-project-id` and `data-transition-name` as data only. - A route can pass `filterState` and mapped `entries`; the server renders the filtered state. - Entries: `register()` at the root, plus `./render`, `./render/register`, `./meta` and `./demo`. Tier `addon`. Requires `@wabbit/tome-blocks-core` 0.35.0 or later and `@wabbit/tome-portfolio` 0.1.0 or later (the pack reads its server API directly); `@wabbit/tome-ui` is an optional peer. - The `index` layout is one client island: type and status chips (and year and place selects in a phone filter sheet) filter in place, with conditional counts and a live count line, and write the URL with `history.replaceState` (restored on `popstate`). A Plates / Ledger toggle is remembered in the cookie `tome-portfolio-view`, and the last filter is kept in `sessionStorage`. Without script the chips and the toggle are GET forms that land on the same URLs. - Layout `selected`: up to three projects as large plates with a three-cell Title Block (scope, lead, result), a button and the photo credit; `rhythm: 'step'` bleeds the first plate and insets the second, and `straddle` lets the last plate cross into the next band from 1024px. Layout `next`: exactly one next project (the layer's `nextPortfolioEntry` walk), image first, with a back link that restores the visitor's filter. - Every listing image (ledger thumbnail, Plates tile, selected plate) carries `data-transition-name` as data only and its link `data-project-id`; no listing sets a `view-transition-name`. - Blocks `project-hero` and `plate-sequence`, the case-study pair. `project-hero` is the top of a project page: breadcrumb, the lead image full bleed (21:9, a 4:5 phone crop) with the title, summary, nine-cell Title Block and description below it on the ground; the lead is preloaded and carries the page's one static `view-transition-name`. `plate-sequence` renders one chapter (finished work, work in progress or drawings) as numbered plates in sizes `bleed`, `column`, `offset`, `pair` and `trio`, with count lines from `numberPlates`, captions that pin beside the plate from 1024px, composed credits, before and after labels and the figures with the sample label. A drawing prints the layer's sanitized inline SVG; on phones each gets an "Open the drawing" button that opens it at full size in a pannable dialog (the pack's one client island, hidden until script runs). - `title-block` `credits` prints its cells in the editor's authored `credits[]` order; the credit row that names the project's lead prints as the Lead cell where it sits, and a project with no such row keeps the lead first. `includeLead` ("Show the lead") turns it off. The Photographer fallback cell is unchanged. - Fix: the read hooks call the layer without `withLayout`, so reading a project never re-enters its own layout blocks (the hooks need `story`, not `layout`). Requires the `@wabbit/tome-portfolio` `withLayout` read option. - `title-block` `practice` gains an optional `sample` note: one `p[data-tb-sample][data-compliance-label]` after the facts (the pack's sample-note pattern), for a demo or sample site. Unset or blank prints nothing; the golden suite's markup is unchanged, and only its config section gains the new field.

  • ab6974a: New package: `@wabbit/tome-blocks-portfolio-pack`, portfolio blocks for a practice that works project by project, written for the Studio theme and reading the `@wabbit/tome-portfolio` project records. - Block `work-index` with four layouts (`feature`, `index`, `selected` and `next`). `feature` is a split hero for one project (the plate, the job number, title, two sentences, the plate count and one button, with the nine-cell Title Block under both); with no project chosen it shows the one flagged Featured. `index` is a ledger of every project filed by job number, with a thumbnail column, type and status chips with live conditional counts, a count line, the featured project quieted as "Featured above" and a thumbnail credits line. Both render on the server and work without script. - The source trio (`source`, the project relationship, the virtual `portfolioData` field) and `makePortfolioReadHook`: one read per request, shared through `req.context`, falling back to the block's own fields when the read fails. - Shared `TitleBlockCells` (the nine cells in a fixed order), the accent marks, and the per-request morph-name registry for the case page. Listing surfaces carry `data-project-id` and `data-transition-name` as data only. - A route can pass `filterState` and mapped `entries`; the server renders the filtered state. - Entries: `register()` at the root, plus `./render`, `./render/register`, `./meta` and `./demo`. Tier `addon`. Requires `@wabbit/tome-blocks-core` 0.35.0 or later and `@wabbit/tome-portfolio` 0.1.0 or later (the pack reads its server API directly); `@wabbit/tome-ui` is an optional peer. - The `index` layout is one client island: type and status chips (and year and place selects in a phone filter sheet) filter in place, with conditional counts and a live count line, and write the URL with `history.replaceState` (restored on `popstate`). A Plates / Ledger toggle is remembered in the cookie `tome-portfolio-view`, and the last filter is kept in `sessionStorage`. Without script the chips and the toggle are GET forms that land on the same URLs. - Layout `selected`: up to three projects as large plates with a three-cell Title Block (scope, lead, result), a button and the photo credit; `rhythm: 'step'` bleeds the first plate and insets the second, and `straddle` lets the last plate cross into the next band from 1024px. Layout `next`: exactly one next project (the layer's `nextPortfolioEntry` walk), image first, with a back link that restores the visitor's filter. - Every listing image (ledger thumbnail, Plates tile, selected plate) carries `data-transition-name` as data only and its link `data-project-id`; no listing sets a `view-transition-name`. - Blocks `project-hero` and `plate-sequence`, the case-study pair. `project-hero` is the top of a project page: breadcrumb, the lead image full bleed (21:9, a 4:5 phone crop) with the title, summary, nine-cell Title Block and description below it on the ground; the lead is preloaded and carries the page's one static `view-transition-name`. `plate-sequence` renders one chapter (finished work, work in progress or drawings) as numbered plates in sizes `bleed`, `column`, `offset`, `pair` and `trio`, with count lines from `numberPlates`, captions that pin beside the plate from 1024px, composed credits, before and after labels and the figures with the sample label. A drawing prints the layer's sanitized inline SVG; on phones each gets an "Open the drawing" button that opens it at full size in a pannable dialog (the pack's one client island, hidden until script runs). - `title-block` `credits` prints its cells in the editor's authored `credits[]` order; the credit row that names the project's lead prints as the Lead cell where it sits, and a project with no such row keeps the lead first. `includeLead` ("Show the lead") turns it off. The Photographer fallback cell is unchanged. - Fix: the read hooks call the layer without `withLayout`, so reading a project never re-enters its own layout blocks (the hooks need `story`, not `layout`). Requires the `@wabbit/tome-portfolio` `withLayout` read option. - `title-block` `practice` gains an optional `sample` note: one `p[data-tb-sample][data-compliance-label]` after the facts (the pack's sample-note pattern), for a demo or sample site. Unset or blank prints nothing; the golden suite's markup is unchanged, and only its config section gains the new field.

Blocks Practice Pack

v0.3.0
v0.3.0minor

807c889: Practice sites can now build a full first-visit page and a provider index from blocks. All additive: existing blocks render as before unless a page selects the new options. - `practice-steps` adds a `long` variant: details and a how-long and who panel per step. - `practice-people-list` adds a `directory` variant: labeled facts and one action along the card bottoms. - `practice-record-header` adds a typed `page` variant for a page with no record behind it. - `practice-book` adds a typed `bookLink` for a page with no person behind it. - `proof-plates` plates can carry a checklist. - The Rounds theme styles all of these. Its closing book block now sets the words beside stacked choices from 1024px, the caption over the words, and the current nav link is marked.

  • 807c889: Practice sites can now build a full first-visit page and a provider index from blocks. All additive: existing blocks render as before unless a page selects the new options. - `practice-steps` adds a `long` variant: details and a how-long and who panel per step. - `practice-people-list` adds a `directory` variant: labeled facts and one action along the card bottoms. - `practice-record-header` adds a typed `page` variant for a page with no record behind it. - `practice-book` adds a typed `bookLink` for a page with no person behind it. - `proof-plates` plates can carry a checklist. - The Rounds theme styles all of these. Its closing book block now sets the words beside stacked choices from 1024px, the caption over the words, and the current nav link is marked.
v0.2.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.2.0minor

dedba9f: A site that holds more than one practice in the same records can now limit each practice block to its own, and the price list can sit beside the plans the practice accepts. The blocks that list records (the need picker, offerings, people, offices, guides and related chips) take an optional slug prefix, `scope`; empty shows every record and the output is unchanged. A scoped need picker drops a tab with no topics left and shows no lead person from outside the scope. The rows layout of the offering list prints each row's time as a subtitle under its title, and, when the practice settings list accepted plans, puts the price list in a panel beside a "Plans we accept" panel with a note and a call action you can write; with no plans listed the rows take the full width as before. The offices block passes through the new hours word of the local pack, and "Seen here" separates people with semicolons when names carry credentials or days, so a comma is never part of a name.

  • dedba9f: A site that holds more than one practice in the same records can now limit each practice block to its own, and the price list can sit beside the plans the practice accepts. The blocks that list records (the need picker, offerings, people, offices, guides and related chips) take an optional slug prefix, `scope`; empty shows every record and the output is unchanged. A scoped need picker drops a tab with no topics left and shows no lead person from outside the scope. The rows layout of the offering list prints each row's time as a subtitle under its title, and, when the practice settings list accepted plans, puts the price list in a panel beside a "Plans we accept" panel with a note and a call action you can write; with no plans listed the rows take the full width as before. The offices block passes through the new hours word of the local pack, and "Seen here" separates people with semicolons when names carry credentials or days, so a comma is never part of a name.
  • 19537a3: The phone menu button now says what it will do: while the menu is open it is named "Close navigation menu" (settable with `triggerCloseLabel`) and reports `aria-expanded`; a credential kind prints in sentence case ("License", not "license"). The rename applies only to a trigger whose own name starts with "Open"; a trigger with another name keeps it, and a closed menu renders exactly as before.
  • 3954ae9: A page can now carry a breadcrumb trail, an urgent or important notice, and prose set beside its review line and a callout, all from blocks an editor composes. The new `breadcrumb` block is a labeled navigation landmark holding an ordered list of links that ends in the current page (`aria-current="page"`); labels wrap at any width, every link is at least 44px tall, an unsafe link shows as text, and no structured data is emitted. The `post-notice` block gains an optional `tone` (standard, important, urgent) and a `headingLevel`: a toned notice gets a mark before its heading and a heavier edge, never color alone, it stays a static note (no alert role), and an untoned notice renders byte for byte as before. The practice pack's reviewed line is now nestable, so it can sit in a layout grid; build the reading layout with a layout grid in its feature-and-sidebar arrangement holding a text block, the reviewed line and a notice. The block catalog lists the new block.
  • 4b831eb: The offering list's price list now sits on the page ground beside the plans panel and prints its sample tag once, in the panel's header. With accepted plans listed, the rows layout defaults to the ground tone instead of the tint, so the two panels read as panels, and "Demo figures" is one tag in the price list's header instead of a tag on every row. With no plans listed nothing changes.
  • 8bd0d91: The header status strip now updates itself like the desk strip, and a map that cannot load says so. The multi-office strip in the chrome announcement slot (`PracticeStatusAnnouncement`) is still rendered on the server, but its status line is now a small client island that recomputes from the same hours on mount, every 30 seconds and when the tab comes back, so a cached page can no longer say "Open now" after closing time while the desk strip below it says closed. Its first render equals the server's, so nothing flickers. When the map's tiles or style are blocked, the maps providers print a plain sentence ("The map could not load. The directions links still work.", settable with `failedMessage`) beside the directions links instead of failing silently.
v0.1.0minor

bfd7c1c: New package: `@wabbit/tome-blocks-practice-pack`, the free block pack for businesses defined by their people, services and offices, reading the records of `@wabbit/tome-practice`. - Thirteen blocks, registered as the `practice-pack` bundle (tier `free`): `practice-desk-strip`, `practice-topic-picker`, `practice-offering-list` (`doors`, `rows`, `detail`), `practice-steps`, `practice-people-list` (`row`, `cards`, `compact`), `practice-offices`, `practice-guide-list`, `practice-record-header` (`person`, `topic`), `practice-path`, `practice-reviewed-line` (`margin`, `card`), `practice-credentials`, `practice-related` (`chips`) and `practice-book`. Plus `PracticeStatusAnnouncement` at `@wabbit/tome-blocks-practice-pack/server`, a multi-office status strip for chrome's `announcementSlot`. - Neutral: no default, option or comment names a trade. The words come from the practice settings; every block that prints them also takes an optional `labels` group that wins for that block alone. - Structure, not look: every block emits public `data-*` hooks (a root `data-practice-<name>`, a band tone, a shared status chip, tabular figures), and its base stylesheet sets layout and spacing from tokens only, with no literal color, radius or shadow. - Honest data: anything with no data hides and the layout closes up; a status with no value shows no chip; a number prints only when the record chose to show it; sample records carry their label from the settings. - `practice-topic-picker` works without JavaScript (radio inputs and CSS), is keyboard accessible with a group label, and shows a closed lead's status with a call action. - `practice-desk-strip` and `practice-offices` wrap the local pack's `local-desk-strip` and `local-locations`, so `@wabbit/tome-blocks-local-pack` 0.9.0 or later is a required peer; `practice-offices` adds a "Seen here" line to each office through the list's per-office slot. - `practice-book` shows the business profile's urgent notice (hidden when empty); text typed in the block overrides it. Typed photos are uploads to the media collection (`mediaCollection`, default `media`), never URLs. `practice-people-list` links to a person's credential verification page when there is one. - Requires `@wabbit/tome-practice` 0.2.0 or later (the roster and group reads).

  • bfd7c1c: New package: `@wabbit/tome-blocks-practice-pack`, the free block pack for businesses defined by their people, services and offices, reading the records of `@wabbit/tome-practice`. - Thirteen blocks, registered as the `practice-pack` bundle (tier `free`): `practice-desk-strip`, `practice-topic-picker`, `practice-offering-list` (`doors`, `rows`, `detail`), `practice-steps`, `practice-people-list` (`row`, `cards`, `compact`), `practice-offices`, `practice-guide-list`, `practice-record-header` (`person`, `topic`), `practice-path`, `practice-reviewed-line` (`margin`, `card`), `practice-credentials`, `practice-related` (`chips`) and `practice-book`. Plus `PracticeStatusAnnouncement` at `@wabbit/tome-blocks-practice-pack/server`, a multi-office status strip for chrome's `announcementSlot`. - Neutral: no default, option or comment names a trade. The words come from the practice settings; every block that prints them also takes an optional `labels` group that wins for that block alone. - Structure, not look: every block emits public `data-*` hooks (a root `data-practice-<name>`, a band tone, a shared status chip, tabular figures), and its base stylesheet sets layout and spacing from tokens only, with no literal color, radius or shadow. - Honest data: anything with no data hides and the layout closes up; a status with no value shows no chip; a number prints only when the record chose to show it; sample records carry their label from the settings. - `practice-topic-picker` works without JavaScript (radio inputs and CSS), is keyboard accessible with a group label, and shows a closed lead's status with a call action. - `practice-desk-strip` and `practice-offices` wrap the local pack's `local-desk-strip` and `local-locations`, so `@wabbit/tome-blocks-local-pack` 0.9.0 or later is a required peer; `practice-offices` adds a "Seen here" line to each office through the list's per-office slot. - `practice-book` shows the business profile's urgent notice (hidden when empty); text typed in the block overrides it. Typed photos are uploads to the media collection (`mediaCollection`, default `media`), never URLs. `practice-people-list` links to a person's credential verification page when there is one. - Requires `@wabbit/tome-practice` 0.2.0 or later (the roster and group reads).

Longform

v0.9.2
v0.9.2patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.9.1patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.9.0minor

3d47fcd: `longformEpigraph` writes hooks a theme can style, as a page block and as a Lexical block in a post body. The blockquote carries `data-epigraph` and `data-design-version` (`CENTERED`, `ACCENT_BORDER` or `VERSE`, written for the default too). The text, the attribution and the source are each wrapped in a span marked `data-epigraph-text`, `data-epigraph-attribution` and `data-epigraph-source`; an epigraph with no attribution or source writes no span for it. Layout, classes and the em-dash attribution are unchanged: removing the new attributes and spans gives the earlier markup back.

  • 3d47fcd: `longformEpigraph` writes hooks a theme can style, as a page block and as a Lexical block in a post body. The blockquote carries `data-epigraph` and `data-design-version` (`CENTERED`, `ACCENT_BORDER` or `VERSE`, written for the default too). The text, the attribution and the source are each wrapped in a span marked `data-epigraph-text`, `data-epigraph-attribution` and `data-epigraph-source`; an epigraph with no attribution or source writes no span for it. Layout, classes and the em-dash attribution are unchanged: removing the new attributes and spans gives the earlier markup back.
v0.8.0minor

b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.

  • b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.
v0.7.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.7.0minor

bd092c2: **BREAKING:** `@wabbit/tome-blocks-console` is now a required peer; fifteen blocks render through its shared cores. **Migration:** install `@wabbit/tome-blocks-console` (`>=0.1.1 <1.0.0`) alongside this package. Every block except the drop cap is a skin over a core shared with the signal theme's matching block. This package's CSS Modules still supply the sizes, spacing, colors, typeface and width, and the page template's side lanes are unchanged, so the blocks keep their look apart from these changes: - Accordion `FAQ`: each question starts with "Q:" and each answer with "A:", in the accent ink. - Cross link: the type badge takes its link type's color (operation red, intel blue, codex gold, external gray) instead of the block's accent. - Data table `COMPARISON`: alternate body rows are banded on the muted surface; the first column is no longer tinted and bold, and a highlighted cell in the first column now shows as highlighted. - Epigraph: the attribution follows an em dash on its own line, with the source on the line beneath it (they sat side by side); in `CENTERED` both are centered. - Footnotes: a hairline sits above the notes. - Spoiler: the reveal control sits over the hidden content, centered, in the same dark style and words, instead of below it. - Image grid `MASONRY`: a picture and its caption no longer split across the two columns. Behavior changes: an accordion's closed answers are in the page from the first paint, collapsed (`hidden`), where they used to be added only on opening, so find-in-page tools, search engines and screen readers reach them; `SINGLE` and `FAQ` still open one row at a time. Tabs keep only the selected tab in the tab order and move with the arrow keys, Home and End. The series nav marks the current part with `aria-current` on the part itself rather than on its list item. Footnotes keep their `fn-<number>` ids. Key facts with a status also carry the status word, visually hidden. Data table header cells are column headers and the caption names the table. A bare media record passed as an image grid picture (an object with a `url`) is drawn as an image with the item's `altText`; a rendered node renders as before. The variant components (`AccordionSingle` and the like) were internal and are gone; each block's `Component.tsx` renders all its variants. Markup changes with the cores (accordion buttons gain `type="button"` and `aria-controls`, tabs gain ids), so site CSS that reached into the old structure through element selectors may need updating; class names are unchanged.

  • bd092c2: **BREAKING:** `@wabbit/tome-blocks-console` is now a required peer; fifteen blocks render through its shared cores. **Migration:** install `@wabbit/tome-blocks-console` (`>=0.1.1 <1.0.0`) alongside this package. Every block except the drop cap is a skin over a core shared with the signal theme's matching block. This package's CSS Modules still supply the sizes, spacing, colors, typeface and width, and the page template's side lanes are unchanged, so the blocks keep their look apart from these changes: - Accordion `FAQ`: each question starts with "Q:" and each answer with "A:", in the accent ink. - Cross link: the type badge takes its link type's color (operation red, intel blue, codex gold, external gray) instead of the block's accent. - Data table `COMPARISON`: alternate body rows are banded on the muted surface; the first column is no longer tinted and bold, and a highlighted cell in the first column now shows as highlighted. - Epigraph: the attribution follows an em dash on its own line, with the source on the line beneath it (they sat side by side); in `CENTERED` both are centered. - Footnotes: a hairline sits above the notes. - Spoiler: the reveal control sits over the hidden content, centered, in the same dark style and words, instead of below it. - Image grid `MASONRY`: a picture and its caption no longer split across the two columns. Behavior changes: an accordion's closed answers are in the page from the first paint, collapsed (`hidden`), where they used to be added only on opening, so find-in-page tools, search engines and screen readers reach them; `SINGLE` and `FAQ` still open one row at a time. Tabs keep only the selected tab in the tab order and move with the arrow keys, Home and End. The series nav marks the current part with `aria-current` on the part itself rather than on its list item. Footnotes keep their `fn-<number>` ids. Key facts with a status also carry the status word, visually hidden. Data table header cells are column headers and the caption names the table. A bare media record passed as an image grid picture (an object with a `url`) is drawn as an image with the item's `altText`; a rendered node renders as before. The variant components (`AccordionSingle` and the like) were internal and are gone; each block's `Component.tsx` renders all its variants. Markup changes with the cores (accordion buttons gain `type="button"` and `aria-controls`, tabs gain ids), so site CSS that reached into the old structure through element selectors may need updating; class names are unchanged.
v0.6.5patch

6530765: CSS files are now copied to `dist/` only by the post-build script; the duplicate tsup `onSuccess` copy is gone. No behavior change, and the published `dist/` is identical.

  • 6530765: CSS files are now copied to `dist/` only by the post-build script; the duplicate tsup `onSuccess` copy is gone. No behavior change, and the published `dist/` is identical.
v0.6.4patch

0aa80a3: Drops the unused `@wabbit/tome-core` peer dependency; nothing in the package imported it.

  • 0aa80a3: Drops the unused `@wabbit/tome-core` peer dependency; nothing in the package imported it.
v0.6.3patch

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.

  • 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.
  • 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.
v0.6.2patch

48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
v0.6.1patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.6.0minor

aef2725: Accent layer unified: `accentVars()` (accent → `--block-accent-*` CSS custom properties) is now canonical in `@wabbit/tome-ui/utils/accent`; dispatch/readout re-export it and their ~19 inline style-object constructions now call it (values byte-identical for both). **longform: VISIBLE CHANGE (hence minor)** — its local ACCENT_MAP had drifted from the canonical palette its own header declared as the migration target; completing the migration shifts longform block accent hues slightly, makes borders match text, switches backgrounds from solid pale to translucent color-mix, and longform now honors `--cop-accent-*` theme overrides for the first time (parity with dispatch/readout). A visual pass on Callout/KeyFacts/DataTable-class blocks is recommended before adopting in a styled site.

  • aef2725: Accent layer unified: `accentVars()` (accent → `--block-accent-*` CSS custom properties) is now canonical in `@wabbit/tome-ui/utils/accent`; dispatch/readout re-export it and their ~19 inline style-object constructions now call it (values byte-identical for both). **longform: VISIBLE CHANGE (hence minor)** — its local ACCENT_MAP had drifted from the canonical palette its own header declared as the migration target; completing the migration shifts longform block accent hues slightly, makes borders match text, switches backgrounds from solid pale to translucent color-mix, and longform now honors `--cop-accent-*` theme overrides for the first time (parity with dispatch/readout). A visual pass on Callout/KeyFacts/DataTable-class blocks is recommended before adopting in a styled site.
  • 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.
v0.5.1patch

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.
v0.5.0minor

90d66fa: Tokenize longform + LMS typography to the platform `--tome-text-*` scale **tome-longform** — every hard-coded `font-size` rem/px literal across the 15 block CSS modules (Accordion, AnchorSection, Aside, AuthorAside, Callout, ChapterDivider, CrossLink, DataTable, ImageGrid, KeyFacts, SeriesNav, Spoiler, TabbedContent + DropCap's custom prop left as-is) now reads the platform type token with the original literal preserved as the fallback — e.g. `font-size: var(--tome-text-sm, 0.875rem)`. `em`-based sizes are left untouched (intentional relative sizing). A Tome-themed consumer (one that loads `@wabbit/tome-ui/tokens`) now gets longform type that tracks the platform scale; a consumer with no Tome tokens renders identically to before (fallback = the prior literal). **Non-breaking, but rendered sizes shift in Tome-themed consumers — re-verify longform visually after upgrading.** **SeriesNav** — `.partLink` (which sits on the TOP_BANNER `--block-accent-bg` surface) now leads its color with `--block-accent-text` before falling back to `--tome-color-muted-foreground`. When `--block-accent-text` is unset this is identical to the prior rule (non-regressive); when a consumer sets an accent background they now have a paired on-accent text hook, closing the cream-on-pastel contrast gap by contract rather than by a hard-coded value (mirrors the existing `.seriesLabel` / `.sidebarLabel` pairing). **tome-lms-ui** — `tokens.css` now bridges the `--lms-font-size-*` scale to `--tome-text-*` (mirroring the existing color-token bridge), so LMS _content_ typography tracks the platform scale instead of being a parallel fixed scale. The previously-undefined `xs` / `base` / `xl` / `2xl` names (used across components with inline fallbacks) are now defined. Fallbacks equal the dominant observed intended size, so a non-Tome consumer is non-breaking. `CertificateTemplate` literals are tokenized onto this scale. Dense sidebar/nav chrome keeps its sub-14px px literals — that is intentional UI density, not reading prose, and was deliberately left untokenized.

  • 90d66fa: Tokenize longform + LMS typography to the platform `--tome-text-*` scale **tome-longform** — every hard-coded `font-size` rem/px literal across the 15 block CSS modules (Accordion, AnchorSection, Aside, AuthorAside, Callout, ChapterDivider, CrossLink, DataTable, ImageGrid, KeyFacts, SeriesNav, Spoiler, TabbedContent + DropCap's custom prop left as-is) now reads the platform type token with the original literal preserved as the fallback — e.g. `font-size: var(--tome-text-sm, 0.875rem)`. `em`-based sizes are left untouched (intentional relative sizing). A Tome-themed consumer (one that loads `@wabbit/tome-ui/tokens`) now gets longform type that tracks the platform scale; a consumer with no Tome tokens renders identically to before (fallback = the prior literal). **Non-breaking, but rendered sizes shift in Tome-themed consumers — re-verify longform visually after upgrading.** **SeriesNav** — `.partLink` (which sits on the TOP_BANNER `--block-accent-bg` surface) now leads its color with `--block-accent-text` before falling back to `--tome-color-muted-foreground`. When `--block-accent-text` is unset this is identical to the prior rule (non-regressive); when a consumer sets an accent background they now have a paired on-accent text hook, closing the cream-on-pastel contrast gap by contract rather than by a hard-coded value (mirrors the existing `.seriesLabel` / `.sidebarLabel` pairing). **tome-lms-ui** — `tokens.css` now bridges the `--lms-font-size-*` scale to `--tome-text-*` (mirroring the existing color-token bridge), so LMS _content_ typography tracks the platform scale instead of being a parallel fixed scale. The previously-undefined `xs` / `base` / `xl` / `2xl` names (used across components with inline fallbacks) are now defined. Fallbacks equal the dominant observed intended size, so a non-Tome consumer is non-breaking. `CertificateTemplate` literals are tokenized onto this scale. Dense sidebar/nav chrome keeps its sub-14px px literals — that is intentional UI density, not reading prose, and was deliberately left untokenized.
v0.4.4patch

497409b: Fix mobile horizontal-overflow in four blocks. `dispatchRedacted` (inline variant), `editorialColophon` (meta row), `longformTabbedContent` (tab/pill strip), and `longformChapterDivider` (numbered row) forced page-level horizontal scroll at narrow viewports (≤375px). Root-cause CSS — the redacted bar caps at `max-width:100%`, the colophon meta wraps, the tab strips scroll horizontally within their own container, and the chapter-divider content can shrink/wrap. No change to ≥768px layout.

  • 497409b: Fix mobile horizontal-overflow in four blocks. `dispatchRedacted` (inline variant), `editorialColophon` (meta row), `longformTabbedContent` (tab/pill strip), and `longformChapterDivider` (numbered row) forced page-level horizontal scroll at narrow viewports (≤375px). Root-cause CSS — the redacted bar caps at `max-width:100%`, the colophon meta wraps, the tab strips scroll horizontally within their own container, and the chapter-divider content can shrink/wrap. No change to ≥768px layout.
v0.4.1patch

`utils/breakout` converged onto the canonical `@wabbit/tome-ui` helper; the local copy is retired (it was an explicit placeholder pending tome-ui shipping the named-line resolver). No behavior change — mappings were already identical named-line grid-columns and the public API (`breakoutWidthField`/`breakoutWidthFieldImageGrid` consts, `resolveBreakoutWidth`, `BreakoutWidth`/`ResolvedBreakoutWidth` types) is preserved. Re-anchors the `@wabbit/tome-ui` peer to 0.9.0.

  • `utils/breakout` converged onto the canonical `@wabbit/tome-ui` helper; the local copy is retired (it was an explicit placeholder pending tome-ui shipping the named-line resolver). No behavior change — mappings were already identical named-line grid-columns and the public API (`breakoutWidthField`/`breakoutWidthFieldImageGrid` consts, `resolveBreakoutWidth`, `BreakoutWidth`/`ResolvedBreakoutWidth` types) is preserved. Re-anchors the `@wabbit/tome-ui` peer to 0.9.0.
v0.4.0minor

3b5e15f: render: new `@wabbit/tome-longform/render` subpath exporting `partitionChapterStreams()` — splits a block list into a prose stream and an order-anchored marginalia stream for the named-line grid's marginalia track. Promoted verbatim-in-behavior from the Wabbit chapter route (`series/[seriesSlug]/[chapterSlug]/marginaliaPartition.ts`), with two deliberate generalizations: (1) no `Chapter['layout']` type coupling — blocks are typed structurally (`PartitionableBlock`), so any consumer's block union works; (2) the marginalia predicate is injectable via `opts.isMarginalia`, with `defaultIsMarginalia` exported as the canonical Wabbit `longform*` + `editorialSidenote` table. Consumers with a different block vocabulary (e.g. a consumer's `signal*` set) pass their own predicate — adding a consumer's marginalia variant no longer requires a tome-longform release. Additive: new subpath only, no change to existing exports or behavior; Wabbit's route-local copy is byte-equivalent in output under the default predicate.

  • 3b5e15f: render: new `@wabbit/tome-longform/render` subpath exporting `partitionChapterStreams()` — splits a block list into a prose stream and an order-anchored marginalia stream for the named-line grid's marginalia track. Promoted verbatim-in-behavior from the Wabbit chapter route (`series/[seriesSlug]/[chapterSlug]/marginaliaPartition.ts`), with two deliberate generalizations: (1) no `Chapter['layout']` type coupling — blocks are typed structurally (`PartitionableBlock`), so any consumer's block union works; (2) the marginalia predicate is injectable via `opts.isMarginalia`, with `defaultIsMarginalia` exported as the canonical Wabbit `longform*` + `editorialSidenote` table. Consumers with a different block vocabulary (e.g. a consumer's `signal*` set) pass their own predicate — adding a consumer's marginalia variant no longer requires a tome-longform release. Additive: new subpath only, no change to existing exports or behavior; Wabbit's route-local copy is byte-equivalent in output under the default predicate.
v0.3.3patch

4225e9f: utils/breakout: re-label `breakoutWidthField` options to reflect the tome-ui 0.8.2 alias repair. Removed misleading pixel hints (`Breakout MD (960px)` / `Breakout LG (1200px)` — the actual widths depend on viewport, marginalia config, and prose-pad config; the px hints didn't reflect any platform reality). Reordered dropdown by monotonic width (article < breakout-md < content ≡ breakout-lg < full-bleed). Flagged `breakout-lg` as `Content (legacy alias)` to surface the new synonym semantic in the admin UI. Updated admin description to name the four width semantics (Article = prose; Breakout = outer reading; Content = full inner content; Full Bleed = edge to edge). No JS resolver change — `BREAKOUT_MAP` keeps its existing `gridColumn` string outputs; the strings resolve to the new alias targets via the platform CSS release. Pairs with `@wabbit/tome-ui@0.8.2` (the grid alias remap). See spec `2026-05-12-tome-ui-grid-breakout-rings-design`.

  • 4225e9f: utils/breakout: re-label `breakoutWidthField` options to reflect the tome-ui 0.8.2 alias repair. Removed misleading pixel hints (`Breakout MD (960px)` / `Breakout LG (1200px)` — the actual widths depend on viewport, marginalia config, and prose-pad config; the px hints didn't reflect any platform reality). Reordered dropdown by monotonic width (article < breakout-md < content ≡ breakout-lg < full-bleed). Flagged `breakout-lg` as `Content (legacy alias)` to surface the new synonym semantic in the admin UI. Updated admin description to name the four width semantics (Article = prose; Breakout = outer reading; Content = full inner content; Full Bleed = edge to edge). No JS resolver change — `BREAKOUT_MAP` keeps its existing `gridColumn` string outputs; the strings resolve to the new alias targets via the platform CSS release. Pairs with `@wabbit/tome-ui@0.8.2` (the grid alias remap). See spec `2026-05-12-tome-ui-grid-breakout-rings-design`.
  • Updated dependencies [4225e9f] - @wabbit/tome-ui@0.8.2
v0.3.2patch

fix(longform): add `width: 100%` to `.blockRoot > *` so flex-display inner variants don't shrink to fit-content 0.3.1 moved the prose `max-width` cap from `.blockRoot` (subgrid item; max-width silently ignored) to `.blockRoot > *` (plain grid item; max-width binds). That fixed the cap for block-display inner variants — Epigraph `.centered` (blockquote), AuthorAside `.indented`, Aside `.inline`, KeyFacts `.compact`, etc. — which naturally fill grid-track width via default block stretching. But flex-display inner variants — ChapterDivider's `.ornamental` / `.numbered` / `.symbol` (all `display: flex; justify-content: center`) — shrink to their flex children's intrinsic width when no explicit `width` is set. The `.ornamental` variant rendered at 18px wide (single-glyph child) instead of 600px wide. `.numbered` and `.symbol` would have collapsed their `flex: 1` rule-line children (`.numberedRule`/`.symbolRule`) to 0 width. Fix: add `width: 100%` to the `.blockRoot > *` rule across all 14 prose-defaulted blocks. The interaction now is: 1. `width: 100%` forces the inner wrapper to fill the parent's grid-track inline-size (~686px at lg+/xl with default settings) 2. `max-width: var(--tome-prose-max-width, none)` clamps to the consumer's cap (e.g., 600px on Wabbit chapter routes) 3. `margin-inline: auto` centers the clamped wrapper within the 686px parent span Block-display inner variants are unaffected (they already filled width naturally; `width: 100%` is redundant but harmless). Flex-display variants now fill 600px and lay out their children correctly. **Affected blocks (14):** same set as 0.3.1 — DropCap, Epigraph, Footnotes, ChapterDivider, SeriesNav (TOP_BANNER + BOTTOM_CARD only), AnchorSection, Accordion, TabbedContent, Callout, Aside (INLINE only), AuthorAside (INDENTED only), KeyFacts (COMPACT only), Spoiler, CrossLink. Non-prose variants (marginalia, breakout-md) unchanged. Caught during the re-walk of staging.wabbit.com after 0.3.1 published: ChapterDivider `.ornamental` on chapter 1 rendered at width=18 instead of width=600. Spec amendment §12.6 documents the flex-display shrink-to-content failure mode alongside §12.5's subgrid+max-width quirk.

  • fix(longform): add `width: 100%` to `.blockRoot > *` so flex-display inner variants don't shrink to fit-content 0.3.1 moved the prose `max-width` cap from `.blockRoot` (subgrid item; max-width silently ignored) to `.blockRoot > *` (plain grid item; max-width binds). That fixed the cap for block-display inner variants — Epigraph `.centered` (blockquote), AuthorAside `.indented`, Aside `.inline`, KeyFacts `.compact`, etc. — which naturally fill grid-track width via default block stretching. But flex-display inner variants — ChapterDivider's `.ornamental` / `.numbered` / `.symbol` (all `display: flex; justify-content: center`) — shrink to their flex children's intrinsic width when no explicit `width` is set. The `.ornamental` variant rendered at 18px wide (single-glyph child) instead of 600px wide. `.numbered` and `.symbol` would have collapsed their `flex: 1` rule-line children (`.numberedRule`/`.symbolRule`) to 0 width. Fix: add `width: 100%` to the `.blockRoot > *` rule across all 14 prose-defaulted blocks. The interaction now is: 1. `width: 100%` forces the inner wrapper to fill the parent's grid-track inline-size (~686px at lg+/xl with default settings) 2. `max-width: var(--tome-prose-max-width, none)` clamps to the consumer's cap (e.g., 600px on Wabbit chapter routes) 3. `margin-inline: auto` centers the clamped wrapper within the 686px parent span Block-display inner variants are unaffected (they already filled width naturally; `width: 100%` is redundant but harmless). Flex-display variants now fill 600px and lay out their children correctly. **Affected blocks (14):** same set as 0.3.1 — DropCap, Epigraph, Footnotes, ChapterDivider, SeriesNav (TOP_BANNER + BOTTOM_CARD only), AnchorSection, Accordion, TabbedContent, Callout, Aside (INLINE only), AuthorAside (INDENTED only), KeyFacts (COMPACT only), Spoiler, CrossLink. Non-prose variants (marginalia, breakout-md) unchanged. Caught during the re-walk of staging.wabbit.com after 0.3.1 published: ChapterDivider `.ornamental` on chapter 1 rendered at width=18 instead of width=600. Spec amendment §12.6 documents the flex-display shrink-to-content failure mode alongside §12.5's subgrid+max-width quirk.
v0.3.1patch

fix(longform): move `max-width: var(--tome-prose-max-width, none)` cap from `.blockRoot` to `.blockRoot > *` inner-wrapper rule across the 14 prose-defaulted blocks **The bug.** 0.3.0 declared `max-width: var(--tome-prose-max-width, none); margin-inline: auto;` on each block's `.blockRoot` rule. But `.blockRoot` is also `display: grid; grid-template-columns: subgrid; width: 100%;` — browsers honor subgrid placement size (the prose-inner track width, ~686px at 1920 viewport with default settings) and **silently ignore `max-width` + `margin-inline: auto` on subgrid items**. Result: longform blocks rendered at the full prose-track width regardless of consumer `--tome-prose-max-width` settings, while MediaBlock's `.breakoutArticle` (a plain block-level grid item) correctly capped at the consumer's set width. **The fix.** Move the cap properties from the outer `.blockRoot` rule (a subgrid item) to the inner `.blockRoot > *` rule (a plain grid item placed at `grid-column: 1 / -1` of the parent subgrid). The inner wrapper IS a non-subgrid grid item; `max-width` binds normally on it. `margin-inline: auto` centers the capped content within the parent's prose-inner span. **Affected blocks (14):** DropCap, Epigraph, Footnotes, ChapterDivider, SeriesNav (TOP_BANNER + BOTTOM_CARD only — SIDEBAR variant stays uncapped at marginalia), AnchorSection, Accordion, TabbedContent, Callout, Aside (INLINE only — LEFT_OVERLAY + RIGHT_OVERLAY stay uncapped at marginalia), AuthorAside (INDENTED only — OVERLAY stays uncapped), KeyFacts (COMPACT only — STRIP stays at breakout-md, SIDEBAR stays at marginalia), Spoiler, CrossLink. Block files with multi-variant `> *` rules split into per-variant rules so only the prose-defaulted variant's children get the cap; marginalia + breakout variants stay uncapped (correct intent — those variants don't sit in the prose track). **Caught by staging walk.** 4 chapters on staging.wabbit.com 2026-05-11 showed longform blocks rendering at 686px while `.copyFocusedContainer` prose rendered at 600px (the Wabbit editorial target). MediaBlock at `breakoutWidth='article'` correctly capped to 600px — proving the consumer-side `--tome-prose-max-width` cascade works. Root cause traced to the subgrid+max-width interaction. Spec amendment §12.5 documents the CSS engine quirk and codifies the inner-wrapper cure as the canonical pattern for future prose-defaulted blocks. **No data migration.** Patch-level; consumer `--tome-prose-max-width` declarations work unchanged.

  • fix(longform): move `max-width: var(--tome-prose-max-width, none)` cap from `.blockRoot` to `.blockRoot > *` inner-wrapper rule across the 14 prose-defaulted blocks **The bug.** 0.3.0 declared `max-width: var(--tome-prose-max-width, none); margin-inline: auto;` on each block's `.blockRoot` rule. But `.blockRoot` is also `display: grid; grid-template-columns: subgrid; width: 100%;` — browsers honor subgrid placement size (the prose-inner track width, ~686px at 1920 viewport with default settings) and **silently ignore `max-width` + `margin-inline: auto` on subgrid items**. Result: longform blocks rendered at the full prose-track width regardless of consumer `--tome-prose-max-width` settings, while MediaBlock's `.breakoutArticle` (a plain block-level grid item) correctly capped at the consumer's set width. **The fix.** Move the cap properties from the outer `.blockRoot` rule (a subgrid item) to the inner `.blockRoot > *` rule (a plain grid item placed at `grid-column: 1 / -1` of the parent subgrid). The inner wrapper IS a non-subgrid grid item; `max-width` binds normally on it. `margin-inline: auto` centers the capped content within the parent's prose-inner span. **Affected blocks (14):** DropCap, Epigraph, Footnotes, ChapterDivider, SeriesNav (TOP_BANNER + BOTTOM_CARD only — SIDEBAR variant stays uncapped at marginalia), AnchorSection, Accordion, TabbedContent, Callout, Aside (INLINE only — LEFT_OVERLAY + RIGHT_OVERLAY stay uncapped at marginalia), AuthorAside (INDENTED only — OVERLAY stays uncapped), KeyFacts (COMPACT only — STRIP stays at breakout-md, SIDEBAR stays at marginalia), Spoiler, CrossLink. Block files with multi-variant `> *` rules split into per-variant rules so only the prose-defaulted variant's children get the cap; marginalia + breakout variants stay uncapped (correct intent — those variants don't sit in the prose track). **Caught by staging walk.** 4 chapters on staging.wabbit.com 2026-05-11 showed longform blocks rendering at 686px while `.copyFocusedContainer` prose rendered at 600px (the Wabbit editorial target). MediaBlock at `breakoutWidth='article'` correctly capped to 600px — proving the consumer-side `--tome-prose-max-width` cascade works. Root cause traced to the subgrid+max-width interaction. Spec amendment §12.5 documents the CSS engine quirk and codifies the inner-wrapper cure as the canonical pattern for future prose-defaulted blocks. **No data migration.** Patch-level; consumer `--tome-prose-max-width` declarations work unchanged.
v0.3.0minor

feat(longform): default 13 of 16 blocks to the new prose track (`prose-start / prose-end`) Per spec 2026-05-11-tome-ui-prose-track-design §4. Each affected block's `.blockRoot` rule changes: ```css /* before */ .blockRoot { grid-column: content-start / content-end; width: 100%; } /* after */ .blockRoot { grid-column: prose-start / prose-end; max-width: var(--tome-prose-max-width, none); margin-inline: auto; width: 100%; } ``` Affected blocks (13; `.blockRoot` only — all per-block variant rules unchanged): - DropCap, Epigraph, Footnotes, ChapterDivider, SeriesNav (TOP_BANNER + BOTTOM_CARD; SIDEBAR variant unchanged), AnchorSection, Accordion, TabbedContent, Callout, Aside (INLINE; LEFT_OVERLAY + RIGHT_OVERLAY variants unchanged at marginalia), AuthorAside (INDENTED; OVERLAY variant unchanged at marginalia), KeyFacts (COMPACT; STRIP at breakout-md + SIDEBAR at marginalia unchanged), Spoiler, CrossLink. Unchanged (stay at content track per design §4): - ImageGrid (`.blockRoot` at content; galleries want wider editorial intent) - DataTable (`.blockRoot` at content; tables benefit from width) Consumer override pattern (matches tome-ui 0.8.0 cap convention): ```css /* on a route or layout wrapper */ .contentWrapper { --tome-prose-max-width: 600px; } ```

  • feat(longform): default 13 of 16 blocks to the new prose track (`prose-start / prose-end`) Per spec 2026-05-11-tome-ui-prose-track-design §4. Each affected block's `.blockRoot` rule changes: ```css /* before */ .blockRoot { grid-column: content-start / content-end; width: 100%; } /* after */ .blockRoot { grid-column: prose-start / prose-end; max-width: var(--tome-prose-max-width, none); margin-inline: auto; width: 100%; } ``` Affected blocks (13; `.blockRoot` only — all per-block variant rules unchanged): - DropCap, Epigraph, Footnotes, ChapterDivider, SeriesNav (TOP_BANNER + BOTTOM_CARD; SIDEBAR variant unchanged), AnchorSection, Accordion, TabbedContent, Callout, Aside (INLINE; LEFT_OVERLAY + RIGHT_OVERLAY variants unchanged at marginalia), AuthorAside (INDENTED; OVERLAY variant unchanged at marginalia), KeyFacts (COMPACT; STRIP at breakout-md + SIDEBAR at marginalia unchanged), Spoiler, CrossLink. Unchanged (stay at content track per design §4): - ImageGrid (`.blockRoot` at content; galleries want wider editorial intent) - DataTable (`.blockRoot` at content; tables benefit from width) Consumer override pattern (matches tome-ui 0.8.0 cap convention): ```css /* on a route or layout wrapper */ .contentWrapper { --tome-prose-max-width: 600px; } ```
  • feat(longform): semantic shift of `breakoutWidth: 'article'` resolved column (BREAKING) Prior: `'article'` → `'content-start / content-end'` (~1830px @ 1920 viewport). Now: `'article'` → `'prose-start / prose-end'` (~800px @ 1920 viewport with default settings). The string `'article'` is unchanged in stored data; only the resolved `grid-column` narrows. Mitigation: blast radius is essentially Wabbit chapter data, which was authored with narrow intent (no migration needed). Pages/Posts/Studies don't author longform blocks at scale. **Note on ImageGrid + DataTable:** their Components apply `breakoutWidth` via local `BREAKOUT_CLASS_MAP`s (not the central `resolveBreakoutWidth` helper). `'article'` field values resolve to their local `.blockRoot` class which still targets `content-start / content-end` — these two blocks render at content width regardless of helper semantics. The label change `'Article (720px)' → 'Article (prose)'` in their option lists is for cross-block consistency only; behavior unchanged for these two.
  • feat(longform): new `breakoutWidth: 'content'` enum value (opt-in for content-track width) Added to `breakoutWidthField`, `ImageGrid/config.ts`, and `DataTable/config.ts` option lists. Position in dropdown: between `'article'` and `'breakout-md'`. Resolved column via `resolveBreakoutWidth`: `'content-start / content-end'`. For consumers wanting the historical wider behavior on prose-tracked blocks. In ImageGrid + DataTable Components, `'content'` falls through their local `BREAKOUT_CLASS_MAP` to the default class (which is `.blockRoot` = content-width). Behaviorally equivalent to `'article'` for these two blocks. For external consumers using `resolveBreakoutWidth` (e.g., custom blocks in wabbit-site-core), `'content'` is the canonical content-width option.
  • feat(longform): widen `BreakoutWidth` type union in inline Component prop interfaces `LongformImageGridData.breakoutWidth` and `LongformDataTableData.breakoutWidth` extended from `'article' | 'breakout-md' | 'breakout-lg' | 'full-bleed'` → adds `'content'`. Behavior unchanged (local maps fall through to default for unrecognized keys); narrowing now type-correct.
  • tome-ui peer floor bumped to `^0.8.0` via `workspace:^` resolution (required for `prose-start / prose-end` lines + `--tome-prose-max-width` custom property + the marginalia `-start/-end` aliases used by SIDEBAR/OVERLAY variants from 0.2.0).
  • KeyFacts SIDEBAR, Aside (LEFT_OVERLAY + RIGHT_OVERLAY), AuthorAside (OVERLAY), SeriesNav (SIDEBAR) variants UNCHANGED in 0.3.0 — their `marginalia-{left,right}-{start,end}` rules now resolve correctly because tome-ui 0.8.0 ships the missing aliases (previously promised but never landed in 0.7.0's grid template).
v0.2.0minor

feat(longform): migrate SIDEBAR/OVERLAY variants from page-padding columns to first-class marginalia tracks Five renderer variants migrate from `margin-{left,right}-*` (24px page padding) to `marginalia-{left,right}-*` named lines that resolve to a readable inner-content track at md+: - `KeyFacts.blockRootSidebar` → `marginalia-right-start / marginalia-right-end` at md+ - `Aside.blockRootRight` → `marginalia-right-start / marginalia-right-end` at md+ - `Aside.blockRootLeft` → `marginalia-left-start / marginalia-left-end` at md+ - `AuthorAside.blockRootOverlay` → `marginalia-right-start / marginalia-right-end` at md+ - `SeriesNav.blockRootSidebar` → `marginalia-right-start / marginalia-right-end` at md+ (5th variant; one-line spec amendment 11.1 — same xl-only pattern as the 4 enumerated in design §4.1) xl+ qualifier dropped — marginalia tracks exist from md+. Below md, variants fall through to the default content-area placement via the `:where(.grid > * > *)` rule. `Aside.LEFT_OVERLAY` at md viewport resolves to 0 width because M_left=0 there; effectively activates at lg+ (documented limitation pending future spec revision). Eliminates the vertical-letter-column rendering bug observed at staging.wabbit.com/tome 2026-05-10 ("AT/A/G/L/A/N/C/E"), where the SIDEBAR variants placed at the 24px right padding column. Requires `@wabbit/tome-ui ≥ 0.7.0`. Peer dep already declared via `workspace:^` and resolves to the new floor on publish.

  • feat(longform): migrate SIDEBAR/OVERLAY variants from page-padding columns to first-class marginalia tracks Five renderer variants migrate from `margin-{left,right}-*` (24px page padding) to `marginalia-{left,right}-*` named lines that resolve to a readable inner-content track at md+: - `KeyFacts.blockRootSidebar` → `marginalia-right-start / marginalia-right-end` at md+ - `Aside.blockRootRight` → `marginalia-right-start / marginalia-right-end` at md+ - `Aside.blockRootLeft` → `marginalia-left-start / marginalia-left-end` at md+ - `AuthorAside.blockRootOverlay` → `marginalia-right-start / marginalia-right-end` at md+ - `SeriesNav.blockRootSidebar` → `marginalia-right-start / marginalia-right-end` at md+ (5th variant; one-line spec amendment 11.1 — same xl-only pattern as the 4 enumerated in design §4.1) xl+ qualifier dropped — marginalia tracks exist from md+. Below md, variants fall through to the default content-area placement via the `:where(.grid > * > *)` rule. `Aside.LEFT_OVERLAY` at md viewport resolves to 0 width because M_left=0 there; effectively activates at lg+ (documented limitation pending future spec revision). Eliminates the vertical-letter-column rendering bug observed at staging.wabbit.com/tome 2026-05-10 ("AT/A/G/L/A/N/C/E"), where the SIDEBAR variants placed at the 24px right padding column. Requires `@wabbit/tome-ui ≥ 0.7.0`. Peer dep already declared via `workspace:^` and resolves to the new floor on publish.
  • Updated dependencies - @wabbit/tome-ui@0.7.0
v0.1.2patch

1d90b24: Subgrid hygiene fix: every direct child of a `blockRoot*` variant now spans the parent subgrid via `grid-column: 1 / -1`. Previously, inner variant wrappers (`.standard`, `.colored`, `.compact`, `.callout`, `.spoilerContainer`, `.dropCapStandard`, `.accordion`, `.faq`, `.bottomCard`, etc.) and descendant content in blocks like DropCap, Callout, Spoiler, and Footnotes (categorized) collapsed into the first cell of the parent subgrid because subgrid children only span 1 track unless explicitly placed. Per-variant `grid-column: 1 / -1` rules added to all 16 longform blocks (Accordion, AnchorSection, Aside, AuthorAside, Callout, ChapterDivider, CrossLink, DataTable, DropCap, Epigraph, Footnotes, ImageGrid, KeyFacts, SeriesNav, Spoiler, TabbedContent), plus a defensive `.blockRoot* > * { grid-column: 1 / -1 }` catch-all that ensures any future inner element honors the subgrid contract regardless of its class name. Surfaced during a consumer's canary rollout phase — `@wabbit/tome-longform` blocks rendered correctly block-level but inner variant wrappers collapsed into cell 1 when the consumer's page wrapper was a real grid (`@wabbit/tome-ui/grid`). Now functions correctly inside both grid and non-grid parents.

  • 1d90b24: Subgrid hygiene fix: every direct child of a `blockRoot*` variant now spans the parent subgrid via `grid-column: 1 / -1`. Previously, inner variant wrappers (`.standard`, `.colored`, `.compact`, `.callout`, `.spoilerContainer`, `.dropCapStandard`, `.accordion`, `.faq`, `.bottomCard`, etc.) and descendant content in blocks like DropCap, Callout, Spoiler, and Footnotes (categorized) collapsed into the first cell of the parent subgrid because subgrid children only span 1 track unless explicitly placed. Per-variant `grid-column: 1 / -1` rules added to all 16 longform blocks (Accordion, AnchorSection, Aside, AuthorAside, Callout, ChapterDivider, CrossLink, DataTable, DropCap, Epigraph, Footnotes, ImageGrid, KeyFacts, SeriesNav, Spoiler, TabbedContent), plus a defensive `.blockRoot* > * { grid-column: 1 / -1 }` catch-all that ensures any future inner element honors the subgrid contract regardless of its class name. Surfaced during a consumer's canary rollout phase — `@wabbit/tome-longform` blocks rendered correctly block-level but inner variant wrappers collapsed into cell 1 when the consumer's page wrapper was a real grid (`@wabbit/tome-ui/grid`). Now functions correctly inside both grid and non-grid parents.
  • Updated dependencies [1d90b24] - @wabbit/tome-ui@0.6.1

Dispatch

v0.3.2
v0.3.2patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.3.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.0minor

b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.

  • b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.
v0.2.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.0minor

67cdd90: **BREAKING:** `@wabbit/tome-blocks-console` is now a required peer; the eight components render through its shared cores. **Migration:** install `@wabbit/tome-blocks-console` (`>=0.1.1 <1.0.0`) alongside this package. Each component is a skin over a core shared with the signal theme's matching blocks; this package's CSS Modules still supply its sizes, spacing, colors and typeface, so the blocks keep their look apart from these changes: - Comm intercept: each message's time moves up onto the callsign's line, after the callsign and a dot separator, instead of sitting below the message. - Redacted `BLOCK_PANEL`: the stamp is filled with the classification color and sits at the left, followed by the caption and a full-width bar standing for the withheld text (it was a centered outlined stamp over the caption). - Redacted `IMAGE`: the placeholder takes the classification color as a dashed edge and shows an icon and the classification word (for example `[RESTRICTED]`); the caption shows only when a `label` is set. Markup changes with the cores (one element where a wrapper and a card were nested; lists and description lists where there were `div`s), so site CSS that reached into the old structure through element selectors may need updating; class names were already hashed.

  • 67cdd90: **BREAKING:** `@wabbit/tome-blocks-console` is now a required peer; the eight components render through its shared cores. **Migration:** install `@wabbit/tome-blocks-console` (`>=0.1.1 <1.0.0`) alongside this package. Each component is a skin over a core shared with the signal theme's matching blocks; this package's CSS Modules still supply its sizes, spacing, colors and typeface, so the blocks keep their look apart from these changes: - Comm intercept: each message's time moves up onto the callsign's line, after the callsign and a dot separator, instead of sitting below the message. - Redacted `BLOCK_PANEL`: the stamp is filled with the classification color and sits at the left, followed by the caption and a full-width bar standing for the withheld text (it was a centered outlined stamp over the caption). - Redacted `IMAGE`: the placeholder takes the classification color as a dashed edge and shows an icon and the classification word (for example `[RESTRICTED]`); the caption shows only when a `label` is set. Markup changes with the cores (one element where a wrapper and a card were nested; lists and description lists where there were `div`s), so site CSS that reached into the old structure through element selectors may need updating; class names were already hashed.
v0.1.13patch

d432a85: Dispatch panels can follow a light theme through the opt-in `@wabbit/tome-ui/console.css`, and threat indicators now have a status word for screen readers. `styles.css` declares `--dispatch-surface: var(--tome-console-surface, hsl(0 0% 6%))` and likewise for the raised surface, line, three ink steps and the critical, elevated, advisory and nominal accents, on `:root` and again (at zero specificity, never inside a cop scope) on any element carrying `data-tome-console="theme"` and on a dark island inside it, so the opt-in works on a wrapper as well as on `<html>`. Blocks build their accent with tome-ui's `consoleAccentVars` / `resolveConsoleAccent`, and each transcript callsign reads the accent role for its own hue (a blue callsign used to borrow the advisory status). The threat, alert, banner, comms and intercept styles read the composition roles (top rule, status bar, hairline, tint strength, SECRET fill strength, band rules, badge outline). Nothing declares the roles by default, so every block renders as before, and a `--dispatch-*` value set by a site or by `@wabbit/tome-cop` still wins. Each threat indicator is now followed by its status word (Active, Monitoring, Resolved), visually hidden by default (the clip pattern, so the layout does not change) and shown when `--tome-console-status-word-display` is set.

  • d432a85: Dispatch panels can follow a light theme through the opt-in `@wabbit/tome-ui/console.css`, and threat indicators now have a status word for screen readers. `styles.css` declares `--dispatch-surface: var(--tome-console-surface, hsl(0 0% 6%))` and likewise for the raised surface, line, three ink steps and the critical, elevated, advisory and nominal accents, on `:root` and again (at zero specificity, never inside a cop scope) on any element carrying `data-tome-console="theme"` and on a dark island inside it, so the opt-in works on a wrapper as well as on `<html>`. Blocks build their accent with tome-ui's `consoleAccentVars` / `resolveConsoleAccent`, and each transcript callsign reads the accent role for its own hue (a blue callsign used to borrow the advisory status). The threat, alert, banner, comms and intercept styles read the composition roles (top rule, status bar, hairline, tint strength, SECRET fill strength, band rules, badge outline). Nothing declares the roles by default, so every block renders as before, and a `--dispatch-*` value set by a site or by `@wabbit/tome-cop` still wins. Each threat indicator is now followed by its status word (Active, Monitoring, Resolved), visually hidden by default (the clip pattern, so the layout does not change) and shown when `--tome-console-status-word-display` is set.
v0.1.12patch

6530765: CSS files are now copied to `dist/` only by the post-build script; the duplicate tsup `onSuccess` copy is gone. No behavior change, and the published `dist/` is identical.

  • 6530765: CSS files are now copied to `dist/` only by the post-build script; the duplicate tsup `onSuccess` copy is gone. No behavior change, and the published `dist/` is identical.
v0.1.11patch

0aa80a3: Drops the unused `@wabbit/tome-core` peer dependency; nothing in the package imported it.

  • 0aa80a3: Drops the unused `@wabbit/tome-core` peer dependency; nothing in the package imported it.
v0.1.10patch

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.

  • 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.
  • 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.
  • 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source already carries.
v0.1.9patch

48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
v0.1.8patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.1.7patch

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: 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.
  • aef2725: Accent layer unified: `accentVars()` (accent → `--block-accent-*` CSS custom properties) is now canonical in `@wabbit/tome-ui/utils/accent`; dispatch/readout re-export it and their ~19 inline style-object constructions now call it (values byte-identical for both). **longform: VISIBLE CHANGE (hence minor)** — its local ACCENT_MAP had drifted from the canonical palette its own header declared as the migration target; completing the migration shifts longform block accent hues slightly, makes borders match text, switches backgrounds from solid pale to translucent color-mix, and longform now honors `--cop-accent-*` theme overrides for the first time (parity with dispatch/readout). A visual pass on Callout/KeyFacts/DataTable-class blocks is recommended before adopting in a styled site.
v0.1.6patch

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.
v0.1.5patch

9683dda: Fix `dispatchRedacted` INLINE variant still forcing page horizontal-overflow at narrow viewports. The earlier `max-width:100%` on `.inline` (0.1.4) was defeated because the bar's fixed inline `width: Nch` sized its own grid track (circular — 100% resolved to the ch-width track). The bar now uses `width: min(${chars}ch, 100%)`, so it shrinks below `chars`ch when the container is narrower than the requested character width. No change at desktop widths (where the container exceeds the ch width).

  • 9683dda: Fix `dispatchRedacted` INLINE variant still forcing page horizontal-overflow at narrow viewports. The earlier `max-width:100%` on `.inline` (0.1.4) was defeated because the bar's fixed inline `width: Nch` sized its own grid track (circular — 100% resolved to the ch-width track). The bar now uses `width: min(${chars}ch, 100%)`, so it shrinks below `chars`ch when the container is narrower than the requested character width. No change at desktop widths (where the container exceeds the ch width).
v0.1.4patch

497409b: Fix mobile horizontal-overflow in four blocks. `dispatchRedacted` (inline variant), `editorialColophon` (meta row), `longformTabbedContent` (tab/pill strip), and `longformChapterDivider` (numbered row) forced page-level horizontal scroll at narrow viewports (≤375px). Root-cause CSS — the redacted bar caps at `max-width:100%`, the colophon meta wraps, the tab strips scroll horizontally within their own container, and the chapter-divider content can shrink/wrap. No change to ≥768px layout.

  • 497409b: Fix mobile horizontal-overflow in four blocks. `dispatchRedacted` (inline variant), `editorialColophon` (meta row), `longformTabbedContent` (tab/pill strip), and `longformChapterDivider` (numbered row) forced page-level horizontal scroll at narrow viewports (≤375px). Root-cause CSS — the redacted bar caps at `max-width:100%`, the colophon meta wraps, the tab strips scroll horizontally within their own container, and the chapter-divider content can shrink/wrap. No change to ≥768px layout.

Readout

v0.3.2
v0.3.2patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.3.1patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.0minor

b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.

  • b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.
v0.2.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.0minor

19a540c: **BREAKING:** `@wabbit/tome-blocks-console` is now a required peer; the nine components render through its shared cores. **Migration:** install `@wabbit/tome-blocks-console` (`>=0.1.1 <1.0.0`) alongside this package. Each component is a skin over a core shared with the signal theme's matching blocks; this package's CSS Modules still supply its sizes, spacing, colors, typeface and widths, so the blocks keep their look apart from two deliberate changes: - Asset card `VERTICAL` and `COMPARISON`: the specs sit two across instead of four, so each value and its label have room. - Metric grid `FOUR_COL` and `THREE_COL`: on screens up to 640px wide the grid drops to two columns. Its earlier narrow-width rules were container queries that only fired inside a site-declared size container; the new rule is a media query, so it applies everywhere, and the container rules still apply where a site declares one. Structure changes for assistive technology, with no visible change: list titles, legend titles and sensor titles are headings (`h3`, phase labels `h4`); objectives and legend entries are lists; asset specs are a description list; and the sensor contacts table scrolls inside a labeled region that a keyboard can reach (it shows a focus ring when tabbed to). The phase marker's title field help no longer says the title is hidden in the Compact variant, which always showed it. Markup changes with the cores (a block's outer box and its panel are one element where they were nested; `section`, headings and lists where there were `div`s and paragraphs), so site CSS that reached into the old structure through element selectors may need updating; class names were already hashed. The `readout-block` class and the `data-variant` and `data-breakout` attributes stay on each block's root.

  • 19a540c: **BREAKING:** `@wabbit/tome-blocks-console` is now a required peer; the nine components render through its shared cores. **Migration:** install `@wabbit/tome-blocks-console` (`>=0.1.1 <1.0.0`) alongside this package. Each component is a skin over a core shared with the signal theme's matching blocks; this package's CSS Modules still supply its sizes, spacing, colors, typeface and widths, so the blocks keep their look apart from two deliberate changes: - Asset card `VERTICAL` and `COMPARISON`: the specs sit two across instead of four, so each value and its label have room. - Metric grid `FOUR_COL` and `THREE_COL`: on screens up to 640px wide the grid drops to two columns. Its earlier narrow-width rules were container queries that only fired inside a site-declared size container; the new rule is a media query, so it applies everywhere, and the container rules still apply where a site declares one. Structure changes for assistive technology, with no visible change: list titles, legend titles and sensor titles are headings (`h3`, phase labels `h4`); objectives and legend entries are lists; asset specs are a description list; and the sensor contacts table scrolls inside a labeled region that a keyboard can reach (it shows a focus ring when tabbed to). The phase marker's title field help no longer says the title is hidden in the Compact variant, which always showed it. Markup changes with the cores (a block's outer box and its panel are one element where they were nested; `section`, headings and lists where there were `div`s and paragraphs), so site CSS that reached into the old structure through element selectors may need updating; class names were already hashed. The `readout-block` class and the `data-variant` and `data-breakout` attributes stay on each block's root.
v0.1.13patch

d432a85: Readout panels can follow a light theme through the opt-in `@wabbit/tome-ui/console.css`, and status dots now have a status word for screen readers. `styles.css` declares `--readout-surface: var(--tome-console-surface, hsl(0 0% 6%))` and likewise for the border, the three text steps and the IFF, personnel and objective colors, on `:root` and again (at zero specificity, never inside a cop scope) on any element carrying `data-tome-console="theme"` and on a dark island inside it, so the opt-in works on a wrapper as well as on `<html>`. Blocks build their accent with tome-ui's `consoleAccentVars`. The sensor and objective panels, phase markers, sensor header and asset category chip read the composition roles (top rule, hairline, tint strength, badge outline). Nothing declares the roles by default, so every block renders as before, and a `--readout-*` value set by a site or by `@wabbit/tome-cop` still wins. The personnel status dot is now followed by its status word and each objective row carries its status word; both are visually hidden by default (the clip pattern, so the layout does not change), and the personnel word shows when `--tome-console-status-word-display` is set. The objective glyph is now `aria-hidden`, since the word replaces it for screen readers.

  • d432a85: Readout panels can follow a light theme through the opt-in `@wabbit/tome-ui/console.css`, and status dots now have a status word for screen readers. `styles.css` declares `--readout-surface: var(--tome-console-surface, hsl(0 0% 6%))` and likewise for the border, the three text steps and the IFF, personnel and objective colors, on `:root` and again (at zero specificity, never inside a cop scope) on any element carrying `data-tome-console="theme"` and on a dark island inside it, so the opt-in works on a wrapper as well as on `<html>`. Blocks build their accent with tome-ui's `consoleAccentVars`. The sensor and objective panels, phase markers, sensor header and asset category chip read the composition roles (top rule, hairline, tint strength, badge outline). Nothing declares the roles by default, so every block renders as before, and a `--readout-*` value set by a site or by `@wabbit/tome-cop` still wins. The personnel status dot is now followed by its status word and each objective row carries its status word; both are visually hidden by default (the clip pattern, so the layout does not change), and the personnel word shows when `--tome-console-status-word-display` is set. The objective glyph is now `aria-hidden`, since the word replaces it for screen readers.
v0.1.12patch

6530765: CSS files are now copied to `dist/` only by the post-build script; the duplicate tsup `onSuccess` copy is gone. No behavior change, and the published `dist/` is identical.

  • 6530765: CSS files are now copied to `dist/` only by the post-build script; the duplicate tsup `onSuccess` copy is gone. No behavior change, and the published `dist/` is identical.
v0.1.11patch

0aa80a3: Drops the unused `@wabbit/tome-core` peer dependency; nothing in the package imported it.

  • 0aa80a3: Drops the unused `@wabbit/tome-core` peer dependency; nothing in the package imported it.
v0.1.10patch

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.

  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
v0.1.9patch

48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
v0.1.8patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.1.7patch

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.

  • 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.
  • 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.
  • aef2725: Accent layer unified: `accentVars()` (accent → `--block-accent-*` CSS custom properties) is now canonical in `@wabbit/tome-ui/utils/accent`; dispatch/readout re-export it and their ~19 inline style-object constructions now call it (values byte-identical for both). **longform: VISIBLE CHANGE (hence minor)** — its local ACCENT_MAP had drifted from the canonical palette its own header declared as the migration target; completing the migration shifts longform block accent hues slightly, makes borders match text, switches backgrounds from solid pale to translucent color-mix, and longform now honors `--cop-accent-*` theme overrides for the first time (parity with dispatch/readout). A visual pass on Callout/KeyFacts/DataTable-class blocks is recommended before adopting in a styled site.
v0.1.6patch

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.
v0.1.3patch

`ReadoutStatStrip` and `ReadoutMetricGrid` now place via `gridColumn` (named grid lines) instead of a pixel `maxWidth` cap, tracking the `@wabbit/tome-ui@0.9.0` breakout change. Rendered width shifts from a centered px-capped box to grid-column placement (verify in a consumer).

  • `ReadoutStatStrip` and `ReadoutMetricGrid` now place via `gridColumn` (named grid lines) instead of a pixel `maxWidth` cap, tracking the `@wabbit/tome-ui@0.9.0` breakout change. Rendered width shifts from a centered px-capped box to grid-column placement (verify in a consumer).

Admin

v0.11.2
v0.11.2patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.11.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.11.0minor

0dc8077: `mergeCollectionAdminComponents` now keeps the Tome edit wrapper on collections that register their own Overview view. - When a collection carries `admin.custom.tomeOverview = true` and already registers an Overview as `views.edit.default`, the Tome `EditDefault` wrapper is assigned to `views.edit.edit.Component` (the `/edit` tab) instead of the collection being skipped, so the form keeps the Tome wrapper. - Only an unset slot or the stock `@payloadcms/ui#DefaultEditView` is replaced; a consumer's own Edit view wins. Every other collection behaves as before. - Re-run `payload generate:importmap` after upgrading.

  • 0dc8077: `mergeCollectionAdminComponents` now keeps the Tome edit wrapper on collections that register their own Overview view. - When a collection carries `admin.custom.tomeOverview = true` and already registers an Overview as `views.edit.default`, the Tome `EditDefault` wrapper is assigned to `views.edit.edit.Component` (the `/edit` tab) instead of the collection being skipped, so the form keeps the Tome wrapper. - Only an unset slot or the stock `@payloadcms/ui#DefaultEditView` is replaced; a consumer's own Edit view wins. Every other collection behaves as before. - Re-run `payload generate:importmap` after upgrading.
v0.10.0minor

eaf29f3: The admin theme switcher now opens a palette in the mode it was designed for: selecting a palette that declares `defaultMode: 'dark'` sets `data-theme="dark"` on the page. - **`defaultMode` crosses the server-to-client list.** `serializeThemes` passes each palette's `defaultMode` (`'light'` or `'dark'`) through to `AdminThemeOption.palettes[]`, which gains the optional field. - **The switcher applies it.** A palette declaring `'dark'` sets `html[data-theme="dark"]`; one declaring `'light'` clears it. Moving to Base, or to a palette that declares nothing, clears a `data-theme` the switcher set itself (marked with `data-tome-mode-forced`) and never one it did not. A palette that declares no `defaultMode`, which is every palette today, leaves `data-theme` untouched, so behavior without the field is unchanged.

  • eaf29f3: The admin theme switcher now opens a palette in the mode it was designed for: selecting a palette that declares `defaultMode: 'dark'` sets `data-theme="dark"` on the page. - **`defaultMode` crosses the server-to-client list.** `serializeThemes` passes each palette's `defaultMode` (`'light'` or `'dark'`) through to `AdminThemeOption.palettes[]`, which gains the optional field. - **The switcher applies it.** A palette declaring `'dark'` sets `html[data-theme="dark"]`; one declaring `'light'` clears it. Moving to Base, or to a palette that declares nothing, clears a `data-theme` the switcher set itself (marked with `data-tome-mode-forced`) and never one it did not. A palette that declares no `defaultMode`, which is every palette today, leaves `data-theme` untouched, so behavior without the field is unchanged.
v0.9.0minor

228b2e4: Consumer branding for the admin shell, and the sidebar now marks the current page. - **`mergeAdminComponents` takes an optional `brand` option.** The shell used to hardcode its identity: the login Logo always said "Tome Admin", the sidebar header always said "Default team", and the shell's Logo/Icon always overrode a site's own `admin.components.graphics`. Now `brand.name` shows in the sidebar header and as the default Logo wordmark (via `admin.custom.tomeBrand`), and `brand.Logo` / `brand.Icon` (import-map paths) take the graphics slots instead of the shell's. Consumers who do not pass `brand` see no change. - **Fix: the sidebar highlights the page you are on.** `NavItem` supported `active` but no caller ever passed it, so nothing was highlighted and no link carried `aria-current`. The grouped nav and the Pinned section now compare the current path to each link (exact match or a `/…` sub-path, so `/admin/collections/media` does not light up `/admin/collections/media-folders`) and set `data-active` and `aria-current="page"`. This reads the path with `usePathname()` from `next/navigation`, so `next` (`>=15.0.0`) is now a declared peer dependency; every Payload admin host already has it. - **Fix: dashboard widgets render for relationship-shaped roles.** 0.8.2 fixed the dashboard resolver, but each widget then ran its own sync `can()` against `req.user`, which on sites whose users carry `roles` as a relationship has role ids only, so every widget rendered nothing. `withWidgetContext` now awaits `canAsync(user, req)` first, populating `_populatedRoles` (cached on `req.context`); it is a no-op when roles are already readable. Widgets wrapped by it now return a Promise, which Payload supports for server-component widgets.

  • 228b2e4: Consumer branding for the admin shell, and the sidebar now marks the current page. - **`mergeAdminComponents` takes an optional `brand` option.** The shell used to hardcode its identity: the login Logo always said "Tome Admin", the sidebar header always said "Default team", and the shell's Logo/Icon always overrode a site's own `admin.components.graphics`. Now `brand.name` shows in the sidebar header and as the default Logo wordmark (via `admin.custom.tomeBrand`), and `brand.Logo` / `brand.Icon` (import-map paths) take the graphics slots instead of the shell's. Consumers who do not pass `brand` see no change. - **Fix: the sidebar highlights the page you are on.** `NavItem` supported `active` but no caller ever passed it, so nothing was highlighted and no link carried `aria-current`. The grouped nav and the Pinned section now compare the current path to each link (exact match or a `/…` sub-path, so `/admin/collections/media` does not light up `/admin/collections/media-folders`) and set `data-active` and `aria-current="page"`. This reads the path with `usePathname()` from `next/navigation`, so `next` (`>=15.0.0`) is now a declared peer dependency; every Payload admin host already has it. - **Fix: dashboard widgets render for relationship-shaped roles.** 0.8.2 fixed the dashboard resolver, but each widget then ran its own sync `can()` against `req.user`, which on sites whose users carry `roles` as a relationship has role ids only, so every widget rendered nothing. `withWidgetContext` now awaits `canAsync(user, req)` first, populating `_populatedRoles` (cached on `req.context`); it is a no-op when roles are already readable. Widgets wrapped by it now return a Promise, which Payload supports for server-component widgets.
v0.8.2patch

479b5ae: The dashboard resolves roles for users whose roles are a relationship, and the `@wabbit/tome-ui` peer floor is now `>=0.13.0`. Dashboard: `createDashboardResolver` read the viewer's role only from `_populatedRoles` or a `role` slug array. A site whose `users` carry `roles` as a relationship has neither in the JWT (only role ids), so no role resolved, every capability-gated widget failed its check, and the dashboard rendered empty ("There are no widgets on your dashboard"). The resolver now awaits `resolveUserCapabilitiesFromReq(req)` first, the same hydration the sidebar already uses: it populates `_populatedRoles` with one depth-1 user read when roles are not readable, and is a no-op otherwise. Found on a consumer site. Peer floor: `shell/SidebarProvider` imports `@wabbit/tome-ui/utils/useMediaQuery`, which `@wabbit/tome-ui` 0.9.0–0.9.8 and 0.12.x do not ship, so a consumer inside the old `>=0.9.0` range failed `next build` with "Module not found". The floor moves to the first release line that ships the subpath continuously.

  • 479b5ae: The dashboard resolves roles for users whose roles are a relationship, and the `@wabbit/tome-ui` peer floor is now `>=0.13.0`. Dashboard: `createDashboardResolver` read the viewer's role only from `_populatedRoles` or a `role` slug array. A site whose `users` carry `roles` as a relationship has neither in the JWT (only role ids), so no role resolved, every capability-gated widget failed its check, and the dashboard rendered empty ("There are no widgets on your dashboard"). The resolver now awaits `resolveUserCapabilitiesFromReq(req)` first, the same hydration the sidebar already uses: it populates `_populatedRoles` with one depth-1 user read when roles are not readable, and is a no-op otherwise. Found on a consumer site. Peer floor: `shell/SidebarProvider` imports `@wabbit/tome-ui/utils/useMediaQuery`, which `@wabbit/tome-ui` 0.9.0–0.9.8 and 0.12.x do not ship, so a consumer inside the old `>=0.9.0` range failed `next build` with "Module not found". The floor moves to the first release line that ships the subpath continuously.
v0.8.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.8.0minor

e59a3ad: **BREAKING:** the sidebar `ThemeSwitcher` writes `data-tome-theme`, lists registered themes, and `DEFAULT_AVAILABLE_PACKS` is removed. It replaces `ThemePackSwitcher`, which wrote `data-tome-pack` and offered a hard-coded list; the themes now come from `@wabbit/tome-core`'s theme registry. **Migration:** upgrade `@wabbit/tome-core` to 1.20 or later (the peer floor moves from 1.3). Import each theme package's `./register` subpath before calling `mergeAdminComponents`, or pass `themes` to it; with no themes registered the switcher offers only "Base". Rename `ThemePackSwitcher`/`ThemePackProvider`/`useThemePack` to `ThemeSwitcher`/`ThemeProvider`/`useTheme`; the old names still work for one minor and now write `data-tome-theme`. `availableThemePacks` on `AdminShellProviders`/`AdminShell` is deprecated in favor of `themes`. - The previous default list named three empty stylesheets that restyled nothing, and the one working theme was never offered. `mergeAdminComponents` now serializes `listThemes()` onto `admin.custom.tomeThemes`; the `Providers` entrypoint passes it to the switcher as data, so no client component reads the registry. - Items are labeled from each theme's manifest `label`; a theme with several palettes lists each palette, written as `data-tome-palette`. - The choice persists under `tome-admin-theme`. A value saved under the old `tome-admin-theme-pack` key is migrated once, and kept only if it names a registered theme. - The palette command "Toggle theme pack" is now "Cycle site theme" (same command id). - The jobs-status widget heading reads the declared `--tome-type-size-xl` (it read the undeclared `--tome-text-xl`).

  • e59a3ad: **BREAKING:** the sidebar `ThemeSwitcher` writes `data-tome-theme`, lists registered themes, and `DEFAULT_AVAILABLE_PACKS` is removed. It replaces `ThemePackSwitcher`, which wrote `data-tome-pack` and offered a hard-coded list; the themes now come from `@wabbit/tome-core`'s theme registry. **Migration:** upgrade `@wabbit/tome-core` to 1.20 or later (the peer floor moves from 1.3). Import each theme package's `./register` subpath before calling `mergeAdminComponents`, or pass `themes` to it; with no themes registered the switcher offers only "Base". Rename `ThemePackSwitcher`/`ThemePackProvider`/`useThemePack` to `ThemeSwitcher`/`ThemeProvider`/`useTheme`; the old names still work for one minor and now write `data-tome-theme`. `availableThemePacks` on `AdminShellProviders`/`AdminShell` is deprecated in favor of `themes`. - The previous default list named three empty stylesheets that restyled nothing, and the one working theme was never offered. `mergeAdminComponents` now serializes `listThemes()` onto `admin.custom.tomeThemes`; the `Providers` entrypoint passes it to the switcher as data, so no client component reads the registry. - Items are labeled from each theme's manifest `label`; a theme with several palettes lists each palette, written as `data-tome-palette`. - The choice persists under `tome-admin-theme`. A value saved under the old `tome-admin-theme-pack` key is migrated once, and kept only if it names a registered theme. - The palette command "Toggle theme pack" is now "Cycle site theme" (same command id). - The jobs-status widget heading reads the declared `--tome-type-size-xl` (it read the undeclared `--tome-text-xl`).
v0.7.2patch

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

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

f900b58: Dashboard widget dates now render in a pinned `en-US` locale and UTC, so every deploy host produces the same markup. Affects the activity feed, pending actions, API keys, form submissions, notifications and user sessions widgets. The jobs-status widget and the content-stats / KPI fallbacks now count rows with a one-row paginated read instead of loading every matching row; counts are unchanged.

  • f900b58: Dashboard widget dates now render in a pinned `en-US` locale and UTC, so every deploy host produces the same markup. Affects the activity feed, pending actions, API keys, form submissions, notifications and user sessions widgets. The jobs-status widget and the content-stats / KPI fallbacks now count rows with a one-row paginated read instead of loading every matching row; counts are unchanged.
v0.7.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • 73081e6: Drop the unused `sonner` peer dependency. `sonner` was declared as a REQUIRED peer (`^1.5.0 || ^2.0.0`) and imported by nothing — the only reference in the package was a comment in `shell/Providers.tsx` reserving a Toaster portal mount that was never built ("not strictly required by Phase 3 exit criteria"). Every consumer that mounted the admin shell was therefore installing a toast library the shell never loads, and a strict-peer installer warned about it. Removing it is the correct direction rather than making it optional: an optional peer still advertises a capability that does not exist. The build seam is untouched — `tsup.config.ts` still externalises `sonner`, so the day the Toaster actually mounts, only the manifest has to move. The comment in `Providers.tsx` now records that trigger explicitly instead of leaving the reader to infer it from a dangling peer. No behavior change: nothing imported it, so nothing can break. Consumers that installed `sonner` only to satisfy this peer can drop it.
  • 637db74: SystemHealth status dots use the bare `--tome-color-success` / `--tome-color-warning` tokens like every sibling widget, dropping two literal hex fallbacks — the only stylelint `color-no-hex` failures in the admin shell, which had kept `lint:css` off the CI gate.
  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
v0.6.10patch

5fc2a42: The sidebar rail now carries its own expand/collapse toggle, rendered by Sidebar itself below the consumer's header slot — previously the only visible toggle lived in tome-admin's shell Header, which consumers using Payload's own admin header (Wabbit prod) never mount, so an accidental Cmd/Ctrl+B collapsed the sidebar with no visible way back. Also guards useKeyboardShortcut against undefined event.key (autofill/synthetic events threw a TypeError in the console).

  • 5fc2a42: The sidebar rail now carries its own expand/collapse toggle, rendered by Sidebar itself below the consumer's header slot — previously the only visible toggle lived in tome-admin's shell Header, which consumers using Payload's own admin header (Wabbit prod) never mount, so an accidental Cmd/Ctrl+B collapsed the sidebar with no visible way back. Also guards useKeyboardShortcut against undefined event.key (autofill/synthetic events threw a TypeError in the console).
v0.6.9patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.6.8patch

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: `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.
  • a93f478: Dashboard performance: nine widget compute waterfalls parallelized (activity-feed, approval-queue, pending-actions, seo-health, scheduled-publishing's per-collection scans; user-sessions, kpi-card, form-submissions independent-fetch folds; admin-pro DevDrawer panel gathers) — per-collection error isolation and result ordering preserved exactly, all 717+87 tests unmodified and green. Also: `useMeCapabilities` extracted from the Nav entrypoint to `nav/useMeCapabilities.ts` with in-flight dedup + TTL cache; `usePinnedItems` gains optional `initialItems`/`initialRowId` seeding (non-breaking) + dedup; CommandPalette query reset moved to mount-by-construction (state lives in the dialog body now); SidebarProvider's mobile-close moved from an effect to the matchMedia event source.
  • aef2725: Monolith decompositions (behavior- and markup-preserving; public APIs unchanged; markup identity mechanically verified per file): forms' FieldRenderer 633→84 via a field-control registry + shared FieldChrome (consent/checkbox byte-identical branches merged) and TomeForm 656→451 via four extracted hooks (the ordering-critical resolver sync deliberately stays inline, documented); rpg's CharacterSheet 841→130 across panels + three editing hooks + persistence hook (the StrictMode XP-ledger charRef guard preserved verbatim); gallery's GalleryIndex 1032→431 (BlockThumb/BlockCard/Toolbar/useFilteredCatalog siblings, T2's debounce+memo preserved); webgl's WebglCanvasProvider 938→546 (useTransitionOrchestrator + useCanvasRenderer extracted; settle thresholds hoisted to named consts); admin's mergeAdminComponents 828→404 orchestrator + four helpers (all docblocks relocated, 717 tests unmodified) and Nav's config-reading now typed (6 of 8 `as any` casts eliminated); marketing-starter's PricingPlans extracts its GSAP toggle timeline hook + a memoized card. rpg additionally trusts the denormalized `xpTotal` on sheet load/save hot paths (full recompute stays at the XP-recording reconciliation point).
v0.6.7patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
  • 77bf66b: Fix a duplicate-key race in `seedTomeAdminLayouts` (`payload/seed-layouts.ts`) that could crash a consumer's Payload `onInit` on a fresh/empty database under concurrency — e.g. a Next.js build's "collecting page data" phase, which spawns many parallel worker processes each triggering Payload init against the same DB. Same defect class as `@wabbit/tome-core`'s `initializeRoles` fix (see its companion changeset), but worse: this seeder's check-then-create (`find` → `create`) had **no** try/catch at all, so the losing worker's duplicate-key rejection propagated uncaught. **Uniqueness ground truth**: the `tome-admin-layouts` collection's `role` field carries `unique: true` (`payload/layouts-collection.ts`), so a real DB-level constraint exists on every supported adapter — the failure mode was a crash, not silent duplicate rows; no schema change needed. **Fix**: catch the create rejection and detect the conflict via Payload's canonical, adapter-agnostic `ValidationError` shape (`instanceof ValidationError` + `data.errors[].path === 'role'`) — all DB adapters normalize their native unique-constraint violations into this shape. On a detected conflict, re-fetch by role to confirm the row now exists; if confirmed, honor the seeder's stated semantics: with `overwrite: false` (default) the winner's row is kept, with `overwrite: true` the winner's row is updated with the seed data (identical between racing workers, so idempotent). A conflict the re-fetch cannot confirm, and any non-unique error, is rethrown rather than silently swallowed. Sequential/single-worker behavior is unchanged.
v0.6.6patch

admin.hidden now works in the sidebar: mergeAdminComponents bridges static-hidden collection/global slugs to the client via admin.custom.tomeHiddenNavSlugs (Payload strips admin.hidden from client config, so the resolver's check could never fire in production); PLATFORM_HIDDEN_SLUGS hides Payload's five sanitize-injected internals (payload-jobs/kv/locked-documents/migrations/preferences) by default with the admin.custom.tomeShowHiddenSystemSlugs consumer opt-in knob. PinnedSection mirrors both channels. Function-valued hidden remains visible by design.

  • admin.hidden now works in the sidebar: mergeAdminComponents bridges static-hidden collection/global slugs to the client via admin.custom.tomeHiddenNavSlugs (Payload strips admin.hidden from client config, so the resolver's check could never fire in production); PLATFORM_HIDDEN_SLUGS hides Payload's five sanitize-injected internals (payload-jobs/kv/locked-documents/migrations/preferences) by default with the admin.custom.tomeShowHiddenSystemSlugs consumer opt-in knob. PinnedSection mirrors both channels. Function-valued hidden remains visible by design.
v0.6.5patch

Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

  • Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
v0.5.6patch

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.
v0.1.1patch

**lms-ui — CSS Modules → plain CSS rename (minor, consumer-visible).** `packages/lms-ui/src/components/*/index.module.css` renamed to `index.css` across 30+ components; exports map in `package.json` updated to match (`./components/*` now point to `index.css` under `dist/`). Consumers switch from `import styles from './index.module.css'` to side-effect `import './index.css'`. The previous layout was rejected by Next.js because the CSS used attribute-based global selectors (`[data-layout="three-column"]`), which CSS Modules flag as non-pure. This rename unblocks cross-package CSS `@import` from consumer barrels (e.g. a consumer's `src/styles/tome-lms-ui.css`). Also bumps the build script to add `NODE_OPTIONS=--max-old-space-size=8192` (DTS was OOM'ing against the peer type graph) and adds `cross-env` as a devDep. **admin — Payload 3.x entrypoint alignment + Turbopack cmdk fix (patch).** `DefaultCommandRegistrar` was split: the Payload-aware variant lives in a new `PayloadDefaultCommandRegistrar.tsx` with a static ESM import of `@payloadcms/ui`. Root cause: Next 15 Turbopack's CJS-of-ESM interop returned `useConfig` as not-a-function under the prior `require('@payloadcms/ui')` lazy-load path. Edit/List/Nav entrypoints now render `<DefaultEditView>` and siblings with `DocumentViewClientProps`, matching Payload 3.x's full-replacement slot contract (the prior HOC shape assumed `children` that Payload never delivered). No public API surface changes.

  • **lms-ui — CSS Modules → plain CSS rename (minor, consumer-visible).** `packages/lms-ui/src/components/*/index.module.css` renamed to `index.css` across 30+ components; exports map in `package.json` updated to match (`./components/*` now point to `index.css` under `dist/`). Consumers switch from `import styles from './index.module.css'` to side-effect `import './index.css'`. The previous layout was rejected by Next.js because the CSS used attribute-based global selectors (`[data-layout="three-column"]`), which CSS Modules flag as non-pure. This rename unblocks cross-package CSS `@import` from consumer barrels (e.g. a consumer's `src/styles/tome-lms-ui.css`). Also bumps the build script to add `NODE_OPTIONS=--max-old-space-size=8192` (DTS was OOM'ing against the peer type graph) and adds `cross-env` as a devDep. **admin — Payload 3.x entrypoint alignment + Turbopack cmdk fix (patch).** `DefaultCommandRegistrar` was split: the Payload-aware variant lives in a new `PayloadDefaultCommandRegistrar.tsx` with a static ESM import of `@payloadcms/ui`. Root cause: Next 15 Turbopack's CJS-of-ESM interop returned `useConfig` as not-a-function under the prior `require('@payloadcms/ui')` lazy-load path. Edit/List/Nav entrypoints now render `<DefaultEditView>` and siblings with `DocumentViewClientProps`, matching Payload 3.x's full-replacement slot contract (the prior HOC shape assumed `children` that Payload never delivered). No public API surface changes.

Admin Pro

v0.2.5
v0.2.5patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.2.4patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.3patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.2patch

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

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

f900b58: The developer drawer's layouts row count, when `payload.count()` is unavailable, now uses a one-row paginated read instead of loading every layout row. The count is unchanged.

  • f900b58: The developer drawer's layouts row count, when `payload.count()` is unavailable, now uses a one-row paginated read instead of loading every layout row. The count is unchanged.
v0.2.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • ce3d12d: Pin the package version this build reports about itself (2026-09-01 sale-readiness audit §3.2, T3(g)). `src/index.ts` declared `TOME_ADMIN_PRO_PACKAGE_VERSION = '0.1.0-rc.2'` while `package.json` said `0.1.0` and the README said `0.1.0-alpha.0` — a three-way drift on a single package's version, in the one package whose job is telling an operator what is installed. Nothing compared any pair of them. The const moves to `src/version.ts` (a leaf module with no imports) as `ADMIN_PRO_PACKAGE_VERSION`, corrected to `0.1.0`, and `src/index.ts` re-exports it under the existing public name — so no consumer import changes. `tests/version.test.ts` compares it to `package.json` and asserts the const has exactly one home: the barrel re-exports rather than re-declaring, and `src/version.ts` imports nothing. Where it surfaces, and why the wrong value is worse than none: the Dev Drawer widget renders it as "which build of admin-pro is this environment running?", read by whoever is debugging a license or widget-registration failure. A stale literal there does not break a build or a test — it silently sends that person in the wrong direction. The Dev Drawer now imports the leaf module rather than the barrel. It was reaching back through `../../index`, which side-effect-imports both widget registries — a cycle that happened to work. This is the same fix nine layer packages already carry for their `registerLayer` literal (`src/version.ts` + `tests/layer-version.test.ts`), with the repo-wide twin in `scripts/assert-layer-version.mjs`. `admin-pro` calls `registerLayer` nowhere — it is an admin-shell app package, not a layer — so that assert has never looked at it, which is exactly how the drift survived. The test is the comparison that was missing.
  • 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.
  • 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.
v0.1.0patch

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: 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.
  • a93f478: Dashboard performance: nine widget compute waterfalls parallelized (activity-feed, approval-queue, pending-actions, seo-health, scheduled-publishing's per-collection scans; user-sessions, kpi-card, form-submissions independent-fetch folds; admin-pro DevDrawer panel gathers) — per-collection error isolation and result ordering preserved exactly, all 717+87 tests unmodified and green. Also: `useMeCapabilities` extracted from the Nav entrypoint to `nav/useMeCapabilities.ts` with in-flight dedup + TTL cache; `usePinnedItems` gains optional `initialItems`/`initialRowId` seeding (non-breaking) + dedup; CommandPalette query reset moved to mount-by-construction (state lives in the dialog body now); SidebarProvider's mobile-close moved from an effect to the matchMedia event source.
v0.1.0-rc.3patch

Peer ranges corrected to evidence-based floors: `@wabbit/tome-admin` `>=0.2.0 <1.0.0` and `@wabbit/tome-core` `>=1.0.0 <2.0.0` (rc.2 shipped stale carets `^0.2.1` / `^0.3.0`; the core range excluded core 1.x entirely). Consumers no longer need `--legacy-peer-deps` to install this package alongside current core/admin.

  • Peer ranges corrected to evidence-based floors: `@wabbit/tome-admin` `>=0.2.0 <1.0.0` and `@wabbit/tome-core` `>=1.0.0 <2.0.0` (rc.2 shipped stale carets `^0.2.1` / `^0.3.0`; the core range excluded core 1.x entirely). Consumers no longer need `--legacy-peer-deps` to install this package alongside current core/admin.

Chrome

v0.31.0
v0.31.0minor

a8e068a: The header probe publishes `--tome-chrome-visible-height`: how much of the viewport top the chrome covers right now. It matches `--tome-chrome-sticky-height` while the header shows and drops to the announcement strip alone while an auto-hiding header is slid away (`data-visible="false"`), so sticky rails and columns can rise with the header instead of holding a gap for it. `chromeVisibleHeight()` is exported for tests and server code. Nothing changes for a page that doesn't read the new property.

  • a8e068a: The header probe publishes `--tome-chrome-visible-height`: how much of the viewport top the chrome covers right now. It matches `--tome-chrome-sticky-height` while the header shows and drops to the announcement strip alone while an auto-hiding header is slid away (`data-visible="false"`), so sticky rails and columns can rise with the header instead of holding a gap for it. `chromeVisibleHeight()` is exported for tests and server code. Nothing changes for a page that doesn't read the new property.
v0.30.2patch

4582f4a: Footers are a real page landmark, and Footer 11's links are easier to tap on phones. Eight footer variants wrapped `footer` in a `section`, which stops browsers treating it as the page footer. The wrapper is now a `div`, with the same class and attributes. Footer 11 names its `nav` "Footer" so it doesn't duplicate the navbar's landmark, and below 48em each of its links gets a 44px row, which makes the footer taller there.

  • 4582f4a: Footers are a real page landmark, and Footer 11's links are easier to tap on phones. Eight footer variants wrapped `footer` in a `section`, which stops browsers treating it as the page footer. The wrapper is now a `div`, with the same class and attributes. Footer 11 names its `nav` "Footer" so it doesn't duplicate the navbar's landmark, and below 48em each of its links gets a 44px row, which makes the footer taller there.
v0.30.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.30.0minor

8bd0d91: Headers can open with a "Skip to main content" link (opt-in), and the phone menu closes when a link in it is tapped. The skip link is opt-in with `header.skipLink`: any object, even an empty one, renders `<a data-skip-link href="#main-content">Skip to main content</a>` as the first focusable element, off-screen until it takes focus and then a solid chip at the top left; `label` and `href` change its text (translate it per locale) and target (the page's `<main>` needs that id). Unset, `null` or `false` renders nothing and the header's output is unchanged byte for byte, so a site that already renders its own skip link keeps it alone. The checkout bar never has one. The phone menu (every navbar variant) is now controlled: a tapped link, to this page or another, closes it, and for an in-page link focus moves to the section instead of back to the menu button, so the page is no longer scrolled behind a menu that stays open.

  • 8bd0d91: Headers can open with a "Skip to main content" link (opt-in), and the phone menu closes when a link in it is tapped. The skip link is opt-in with `header.skipLink`: any object, even an empty one, renders `<a data-skip-link href="#main-content">Skip to main content</a>` as the first focusable element, off-screen until it takes focus and then a solid chip at the top left; `label` and `href` change its text (translate it per locale) and target (the page's `<main>` needs that id). Unset, `null` or `false` renders nothing and the header's output is unchanged byte for byte, so a site that already renders its own skip link keeps it alone. The checkout bar never has one. The phone menu (every navbar variant) is now controlled: a tapped link, to this page or another, closes it, and for an in-page link focus moves to the section instead of back to the menu button, so the page is no longer scrolled behind a menu that stays open.
  • 19537a3: The phone menu button now says what it will do: while the menu is open it is named "Close navigation menu" (settable with `triggerCloseLabel`) and reports `aria-expanded`; a credential kind prints in sentence case ("License", not "license"). The rename applies only to a trigger whose own name starts with "Open"; a trigger with another name keeps it, and a closed menu renders exactly as before.
v0.29.0minor

e6aceb2: Header call: a visible number, an `{opens}` token and closed-state labels. All opt-in; a header that sets none of them renders byte-identical markup, held by a test. `headerCall.showNumber` prints the phone number as text, with the label in `data-header-call-label` and the number in `data-header-call-number` (off, the number stays screen-reader-only and neither hook is emitted). `{opens}` in a call label is filled with the next opening while closed ("Call when we open: Wed 9:00 am"); with no known opening the token's clause drops, as `{until}` does. `headerCall.closedLabel`, `mobileActionBar.callClosedLabel` and `mobileActionBar.ctaClosedLabel` replace a label while the desk is known to be closed. Only the label of the primary action changes, so it is never hidden while closed. The open snapshot gains an optional `opens` string, set only while closed. Schema: four new optional columns on the Header global (`headerCall.showNumber`, `headerCall.closedLabel`, `mobileActionBar.callClosedLabel`, `mobileActionBar.ctaClosedLabel`; the three labels are localized), so SQL sites need a migration. Mongo sites need none. Header CTA buttons in the desktop row are not hours-aware.

  • e6aceb2: Header call: a visible number, an `{opens}` token and closed-state labels. All opt-in; a header that sets none of them renders byte-identical markup, held by a test. `headerCall.showNumber` prints the phone number as text, with the label in `data-header-call-label` and the number in `data-header-call-number` (off, the number stays screen-reader-only and neither hook is emitted). `{opens}` in a call label is filled with the next opening while closed ("Call when we open: Wed 9:00 am"); with no known opening the token's clause drops, as `{until}` does. `headerCall.closedLabel`, `mobileActionBar.callClosedLabel` and `mobileActionBar.ctaClosedLabel` replace a label while the desk is known to be closed. Only the label of the primary action changes, so it is never hidden while closed. The open snapshot gains an optional `opens` string, set only while closed. Schema: four new optional columns on the Header global (`headerCall.showNumber`, `headerCall.closedLabel`, `mobileActionBar.callClosedLabel`, `mobileActionBar.ctaClosedLabel`; the three labels are localized), so SQL sites need a migration. Mongo sites need none. Header CTA buttons in the desktop row are not hours-aware.
v0.28.0minor

15070a6: Navbar 1 panel groups can be laid out as a row, and Navbars 1 to 4 honor the header's home label. Optional per-subitem `groupLayout` (`'column' | 'row'`) on Navbar 1 grouped panels: the first subitem of a group that sets `'row'` makes the group's wrapper carry `data-nav-panel-layout="row"` (absent otherwise, so default markup is unchanged) for themes to lay it out full width. Navbars 1 to 4 also stop hard-coding `aria-label="Home"` on the logo link and use `header.homeLabel` via `getHomeLinkLabel`. Unset, null or blank `homeLabel` still yields exactly "Home" (the logo alt is deliberately not used as a fallback in these four, so default markup is unchanged).

  • 15070a6: Navbar 1 panel groups can be laid out as a row, and Navbars 1 to 4 honor the header's home label. Optional per-subitem `groupLayout` (`'column' | 'row'`) on Navbar 1 grouped panels: the first subitem of a group that sets `'row'` makes the group's wrapper carry `data-nav-panel-layout="row"` (absent otherwise, so default markup is unchanged) for themes to lay it out full width. Navbars 1 to 4 also stop hard-coding `aria-label="Home"` on the logo link and use `header.homeLabel` via `getHomeLinkLabel`. Unset, null or blank `homeLabel` still yields exactly "Home" (the logo alt is deliberately not used as a fallback in these four, so default markup is unchanged).
  • b328e87: Header: set where the logo links to, and a different word on the phone menu button while it is open. Two optional header fields, both unset by default so existing headers render unchanged. `homeHref` sets the logo's home link (default "/"), read by every built-in navbar (1 to 7), which each hard-coded "/" before. `mobileMenuCloseText` (Navbar 1 and NavBar5, with `mobileMenuLabel: 'word'`) is the word shown on the toggle while the menu is open, for example "Close"; the visible text stays the accessible name and `aria-expanded` still comes from the sheet trigger. `MobileMenuWordTrigger` gains a `closeWord` prop and `_shared` exports `resolveHomeHref` and `resolveMenuCloseWord`.
  • 084a01a: Navbar 1 can show a grouped Courses panel with columns and a footer link. Navbar 1 gains an opt-in grouped Courses panel. A `sub` item takes `panelLayout: 'columns'` and `panelFooterLink`; each subitem takes `group`, `groupHref` and `featured`. Hooks: `div[data-nav-panel-columns]`, `div[data-nav-panel-group]`, `p[data-nav-panel-caption]`, `li[data-nav-featured]`, `a[data-nav-panel-footer]`, and `data-nav-has-columns` on the bar. The phone accordion carries the same groups. `subitems.maxRows` rises from 10 to 24. A `sub` item with no `panelLayout` renders exactly as before.
v0.27.0minor

99d71a8: The phone bar can now carry a second action, such as "Directions" beside "Book a visit". Two new Header fields, `mobileActionBar.secondaryLabel` and `mobileActionBar.secondaryHref`, add an outlined button between Call and the primary action. It shows only when both are filled and works with the phone left blank. With Call, the second action and the primary action together the bar becomes three columns, and labels wrap to two lines instead of truncating. Themes can target `[data-action='secondary']`, and `data-actions` now counts up to 3. Header docs that leave the new fields blank render exactly as before. On a SQL database the two fields are new nullable columns, so regenerate your migration. Also new, opt-in: `mobileActionBar.showAtLoad` (a checkbox, off by default). The bar normally slides in once the visitor scrolls past the first screen; with `showAtLoad` on it is visible from the first paint and stays, with no scroll listener running. `MobileActionBar` takes a matching `showAtLoad` prop, and `resolveMobileActionBar` returns `showAtLoad: true` only when it is on. Header docs that leave it off render exactly as before. On a SQL database it is one more column in the same migration.

  • 99d71a8: The phone bar can now carry a second action, such as "Directions" beside "Book a visit". Two new Header fields, `mobileActionBar.secondaryLabel` and `mobileActionBar.secondaryHref`, add an outlined button between Call and the primary action. It shows only when both are filled and works with the phone left blank. With Call, the second action and the primary action together the bar becomes three columns, and labels wrap to two lines instead of truncating. Themes can target `[data-action='secondary']`, and `data-actions` now counts up to 3. Header docs that leave the new fields blank render exactly as before. On a SQL database the two fields are new nullable columns, so regenerate your migration. Also new, opt-in: `mobileActionBar.showAtLoad` (a checkbox, off by default). The bar normally slides in once the visitor scrolls past the first screen; with `showAtLoad` on it is visible from the first paint and stays, with no scroll listener running. `MobileActionBar` takes a matching `showAtLoad` prop, and `resolveMobileActionBar` returns `showAtLoad: true` only when it is on. Header docs that leave it off render exactly as before. On a SQL database it is one more column in the same migration.
v0.26.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.26.0minor

d747a5f: Navbar 1 gains an opt-in `header.mobileBreakpoint` option: `'64em'` (the default, 1024px) or `'56.25em'` (900px) sets the width at which the full bar replaces the "Menu" button. The option writes `data-mobile-breakpoint="56.25em"` on the section root and the matching CSS lives in chrome's own stylesheet; the Call button and the mobile action bar follow the same width. Unset, the markup and CSS behavior are byte-identical (pinned by the stored Navbar 1 snapshot). The admin field shows for Navbar 1 only. The phone menu's link list (Navbar 1 and Navbar 5) now carries `data-mobile-menu-list`, an additive attribute for themes that number or rule the links. Navbar 1's sheet has no free-text row, so there is no `data-mobile-menu-text` hook.

  • d747a5f: Navbar 1 gains an opt-in `header.mobileBreakpoint` option: `'64em'` (the default, 1024px) or `'56.25em'` (900px) sets the width at which the full bar replaces the "Menu" button. The option writes `data-mobile-breakpoint="56.25em"` on the section root and the matching CSS lives in chrome's own stylesheet; the Call button and the mobile action bar follow the same width. Unset, the markup and CSS behavior are byte-identical (pinned by the stored Navbar 1 snapshot). The admin field shows for Navbar 1 only. The phone menu's link list (Navbar 1 and Navbar 5) now carries `data-mobile-menu-list`, an additive attribute for themes that number or rule the links. Navbar 1's sheet has no free-text row, so there is no `data-mobile-menu-text` hook.
v0.25.0minor

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

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

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

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

2be6b62: Chrome gains a cart control seam, a reduced checkout mode, numbered nav items and a bar-padding token. All four are opt-in, and the default output of every navbar and footer is byte-identical (pinned by stored pre-change snapshots). `cartSlot` (HeaderRenderer prop, navbars 1 to 5 and 7) is a node, or a render function called per place, rendered after `accountSlot` on desktop and in the mobile menu, each copy inside `span[data-cart-slot="desktop|drawer|bar"]`. On Navbar 5, `cartSlotPhone: 'bar'` puts it in the phone bar beside the menu button instead of in the menu. Types: `TomeCartSlot`, `TomeCartSlotContext`; helpers `renderCartSlot` and `withCartSlot`. `chromeMode: 'checkout'` on `HeaderRenderer` and `FooterRenderer` swaps in a reduced chrome. The header shows the logo (not linked), a label (`checkoutChrome.label`, default "Secure checkout") and a back link (`checkoutChrome.back`), and drops navigation, search, CTAs, account, cart, the announcement strip and the mobile action bar. The footer keeps `finePrint`, the copyright line and the legal links, so a credits line shows wherever the pictures do. Both roots carry `data-chrome-mode="checkout"`; the pieces are also exported as `CheckoutHeaderBar` and `CheckoutFooter`. `TomeNavItem.prefix` (link and sub items, new admin field shown for design 5) renders `span[data-nav-prefix]` (decorative, `aria-hidden`) before the label in Navbar 5's bar and phone menu. Navbar 5's section block padding is now `var(--tome-navbar5-pad-block, 1rem)`, so a theme can set the token to `0` and let its own `min-height` own the bar's height (the 73px header against 77px). The checkout header honors `header.logoPhone` below 40em, as Navbar 5 does: the full logo and the phone logo sit in `data-logo-full` and `data-logo-phone` wrappers and the stylesheet swaps them. With `logoPhone` unset the checkout bar's markup is unchanged (pinned by a stored snapshot). Polish: Footer 7 renders its bottom bar (`data-footer-bottom`) only when the footer has a copyright, legal links or the cookie-settings trigger. A footer with none of them used to render an empty bar that still carried its margin, rule and padding (about 41px of dead space). Footers with content render the same bytes as before (pinned by stored snapshots); the only markup that changes is the empty bar, which is gone. Phone bar order: a new Navbar 5 header field `cartSlotPhoneOrder` (`'before-menu'` default, `'after-menu'`), used with `cartSlotPhone: 'bar'`. `'after-menu'` renders the menu trigger first and the bar cart slot after it in the DOM, so keyboard focus follows the visual order (a CSS `order` would not). The default is today's markup, byte-identical (pinned by the stored snapshots); other navbars ignore the field.

  • 2be6b62: Chrome gains a cart control seam, a reduced checkout mode, numbered nav items and a bar-padding token. All four are opt-in, and the default output of every navbar and footer is byte-identical (pinned by stored pre-change snapshots). `cartSlot` (HeaderRenderer prop, navbars 1 to 5 and 7) is a node, or a render function called per place, rendered after `accountSlot` on desktop and in the mobile menu, each copy inside `span[data-cart-slot="desktop|drawer|bar"]`. On Navbar 5, `cartSlotPhone: 'bar'` puts it in the phone bar beside the menu button instead of in the menu. Types: `TomeCartSlot`, `TomeCartSlotContext`; helpers `renderCartSlot` and `withCartSlot`. `chromeMode: 'checkout'` on `HeaderRenderer` and `FooterRenderer` swaps in a reduced chrome. The header shows the logo (not linked), a label (`checkoutChrome.label`, default "Secure checkout") and a back link (`checkoutChrome.back`), and drops navigation, search, CTAs, account, cart, the announcement strip and the mobile action bar. The footer keeps `finePrint`, the copyright line and the legal links, so a credits line shows wherever the pictures do. Both roots carry `data-chrome-mode="checkout"`; the pieces are also exported as `CheckoutHeaderBar` and `CheckoutFooter`. `TomeNavItem.prefix` (link and sub items, new admin field shown for design 5) renders `span[data-nav-prefix]` (decorative, `aria-hidden`) before the label in Navbar 5's bar and phone menu. Navbar 5's section block padding is now `var(--tome-navbar5-pad-block, 1rem)`, so a theme can set the token to `0` and let its own `min-height` own the bar's height (the 73px header against 77px). The checkout header honors `header.logoPhone` below 40em, as Navbar 5 does: the full logo and the phone logo sit in `data-logo-full` and `data-logo-phone` wrappers and the stylesheet swaps them. With `logoPhone` unset the checkout bar's markup is unchanged (pinned by a stored snapshot). Polish: Footer 7 renders its bottom bar (`data-footer-bottom`) only when the footer has a copyright, legal links or the cookie-settings trigger. A footer with none of them used to render an empty bar that still carried its margin, rule and padding (about 41px of dead space). Footers with content render the same bytes as before (pinned by stored snapshots); the only markup that changes is the empty bar, which is gone. Phone bar order: a new Navbar 5 header field `cartSlotPhoneOrder` (`'before-menu'` default, `'after-menu'`), used with `cartSlotPhone: 'bar'`. `'after-menu'` renders the menu trigger first and the bar cart slot after it in the DOM, so keyboard focus follows the visual order (a CSS `order` would not). The default is today's markup, byte-identical (pinned by the stored snapshots); other navbars ignore the field.
  • 06221cd: Navbar 5 gains an opt-in centered navigation and reports its scrolled state, so a theme can center the links and restyle the bar on scroll. Setting the existing Header field `navAlignment` to `center` on a Navbar 5 header (the field now shows for designs 4 and 5) puts the links on the true center of the bar from the desktop breakpoint up, whatever the widths of the logo and the actions, and writes `data-nav-align="center"` on the section root. With it on, the navbar also reports whether the page has scrolled past 20px, and the header frame carries `data-nav-scrolled="true|false"`. Left at the default there is no attribute, no scroll listener and no change to the markup. `useReportNavScrolled(scrolled, enabled?)` takes an optional second argument for a navbar that opts in conditionally. Navbar 5 also gains an opt-in phone menu button. The Header field `mobileMenuLabel` (`icon` by default, or `word`) swaps the hamburger icon for a text button, `button[data-mobile-menu-trigger="word"]`, whose visible word (the localised `mobileMenuText`, default "Menu") is its accessible name and which opens the same sheet. Left at `icon` the markup is unchanged. Navbar 5's phone layout no longer overflows: the phone action group (search, call, cart, Menu) shrinks and wraps, and the logo group can shrink (the logo keeps its aspect ratio inside its fixed height) instead of pushing the bar past the viewport (102px at 390 with a wide wordmark). Opt-in phone lockup: the new Header field `logoPhone` (an upload, Navbar 5 only) is a phone-width logo, such as the wordmark alone. When set, the home link holds `span[data-logo-full]` (the main logo) and `span[data-logo-phone]`, and below 40em only the phone one shows. Unset, the markup is byte-identical.
v0.22.1patch

navbar4: plain top-level links get the same side padding as dropdown triggers, so links and triggers space evenly. New `--tome-chrome-nav-item-pad-x` (default `--tome-space-lg`) sets both.

  • navbar4: plain top-level links get the same side padding as dropdown triggers, so links and triggers space evenly. New `--tome-chrome-nav-item-pad-x` (default `--tome-space-lg`) sets both.
v0.22.0minor

0fc39b7: Navbar 4 gains opt-in centered navigation, an underline open-menu indicator and an accordion mobile menu, and its mobile sheet now locks page scroll and manages focus. New Header global fields, all shown only for Navbar 4 and defaulting to the current look, so existing headers render unchanged: `navAlignment` (`between` | `center`) centers the top-level nav on the true center of the bar from the desktop breakpoint up, with the desktop search button kept in the end column; `menuOpenIndicator` (`fill` | `underline`) replaces the accent fill on an open or hovered item with a 2px accent bar at the bottom edge of the header, for mega triggers, dropdown triggers and the current page's link; `mobileMenuStyle` (`drillIn` | `accordion`) expands submenus inline in the mobile sheet with compact `simpleLinks` and `featuredImage` blocks (`BlockRenderer` takes a new `compact` prop), a pinned footer holding the account, language, theme and CTA area, and the copyright at the end of the list. `ctaVisibleBelowDesktop` now also applies to Navbar 4: the CTA buttons show in the bar on phones too, and with the accordion they hide while the sheet is open since the pinned footer repeats them. The three new fields add database columns: after upgrading, run your Payload migration and regenerate types. Two optional tokens, `--tome-chrome-nav-item-size` and `--tome-chrome-nav-item-weight`, set one size and one weight for every top-level desktop item, links and triggers alike; unset, each keeps its current value. Accessibility fixes for every Navbar 4 mobile sheet (both styles): page scroll is locked while it is open and the previous overflow restored on close; the sheet sizes to the dynamic viewport below the measured header height (`--site-header-bar-height`, falling back to 4.5rem) and scrolls with `overflow-y: auto`; focus moves into the sheet on open, Tab and Shift+Tab stay inside the toggle and the sheet, and Escape closes it and returns focus to the toggle. In drill-in mode, focus moves to the submenu heading on entry and back to the opening row on "Go back".

  • 0fc39b7: Navbar 4 gains opt-in centered navigation, an underline open-menu indicator and an accordion mobile menu, and its mobile sheet now locks page scroll and manages focus. New Header global fields, all shown only for Navbar 4 and defaulting to the current look, so existing headers render unchanged: `navAlignment` (`between` | `center`) centers the top-level nav on the true center of the bar from the desktop breakpoint up, with the desktop search button kept in the end column; `menuOpenIndicator` (`fill` | `underline`) replaces the accent fill on an open or hovered item with a 2px accent bar at the bottom edge of the header, for mega triggers, dropdown triggers and the current page's link; `mobileMenuStyle` (`drillIn` | `accordion`) expands submenus inline in the mobile sheet with compact `simpleLinks` and `featuredImage` blocks (`BlockRenderer` takes a new `compact` prop), a pinned footer holding the account, language, theme and CTA area, and the copyright at the end of the list. `ctaVisibleBelowDesktop` now also applies to Navbar 4: the CTA buttons show in the bar on phones too, and with the accordion they hide while the sheet is open since the pinned footer repeats them. The three new fields add database columns: after upgrading, run your Payload migration and regenerate types. Two optional tokens, `--tome-chrome-nav-item-size` and `--tome-chrome-nav-item-weight`, set one size and one weight for every top-level desktop item, links and triggers alike; unset, each keeps its current value. Accessibility fixes for every Navbar 4 mobile sheet (both styles): page scroll is locked while it is open and the previous overflow restored on close; the sheet sizes to the dynamic viewport below the measured header height (`--site-header-bar-height`, falling back to 4.5rem) and scrolls with `overflow-y: auto`; focus moves into the sheet on open, Tab and Shift+Tab stay inside the toggle and the sheet, and Escape closes it and returns focus to the toggle. In drill-in mode, focus moves to the submenu heading on entry and back to the opening row on "Go back".
v0.21.0minor

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

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

2e9f1ab: The header Call action and the mobile action bar can now dial internationally: pass `defaultCallingCode` to `HeaderRenderer` and a typed national number becomes an E.164 `tel:` link. `<HeaderRenderer defaultCallingCode="1" />` turns `(816) 555-0118` into `tel:+18165550118`; a number typed with a leading `+` is unchanged, and leaving the prop unset keeps today's digits-only hrefs. It is a code-level site option, not an admin field. `toTelHref`, `resolveHeaderCall` and `resolveMobileActionBar` take an optional `{ defaultCallingCode }` last argument, and `HeaderCallingCodeProvider` / `useHeaderCallingCode` carry the value to `HeaderCallButton` (now a client module, like the other context readers). Extensions typed as `ext. 12` now produce `;ext=12` instead of the digits run together. Requires `@wabbit/tome-core` 1.22.0 or later.

  • 2e9f1ab: The header Call action and the mobile action bar can now dial internationally: pass `defaultCallingCode` to `HeaderRenderer` and a typed national number becomes an E.164 `tel:` link. `<HeaderRenderer defaultCallingCode="1" />` turns `(816) 555-0118` into `tel:+18165550118`; a number typed with a leading `+` is unchanged, and leaving the prop unset keeps today's digits-only hrefs. It is a code-level site option, not an admin field. `toTelHref`, `resolveHeaderCall` and `resolveMobileActionBar` take an optional `{ defaultCallingCode }` last argument, and `HeaderCallingCodeProvider` / `useHeaderCallingCode` carry the value to `HeaderCallButton` (now a client module, like the other context readers). Extensions typed as `ext. 12` now produce `;ext=12` instead of the digits run together. Requires `@wabbit/tome-core` 1.22.0 or later.
v0.19.0minor

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.
v0.18.0minor

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.
v0.17.1patch

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.
v0.17.0minor

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.
v0.16.0minor

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 behavior). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `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` honors `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 behavior). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `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` honors `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
v0.15.0minor

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.
v0.14.1patch

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.
v0.14.0minor

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.
v0.13.1patch

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.
v0.13.0minor

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'`.
v0.12.1patch

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.
v0.12.0minor

e6037f6: Navbar 7 now honors a `LogoComponent` override, and footers gain `--tome-footer-pad-block`, `--tome-footer-nav-cols` and a `data-footer-nav` hook. Navbar 7 used to render its stacked `logo`/`logoDark` images even when `HeaderRenderer` received a `LogoComponent`. The override now replaces them inside a `[data-navbar-logo="component"]` wrapper that inherits the bar's text color, and receives `isDarkModeEnabled: true` whenever the bar wants its light-on-dark logo. Without an override, the `navTheme` image behavior is unchanged. Footers 1–5, 7 and 8 read their block padding from `--tome-footer-pad-block` (default `8rem`). Footer 7's link grid carries `data-footer-nav` and reads its column count from `--tome-footer-nav-cols` (default `3`). The defaults match the previous fixed values, so nothing changes until a theme sets them.

  • e6037f6: Navbar 7 now honors a `LogoComponent` override, and footers gain `--tome-footer-pad-block`, `--tome-footer-nav-cols` and a `data-footer-nav` hook. Navbar 7 used to render its stacked `logo`/`logoDark` images even when `HeaderRenderer` received a `LogoComponent`. The override now replaces them inside a `[data-navbar-logo="component"]` wrapper that inherits the bar's text color, and receives `isDarkModeEnabled: true` whenever the bar wants its light-on-dark logo. Without an override, the `navTheme` image behavior is unchanged. Footers 1–5, 7 and 8 read their block padding from `--tome-footer-pad-block` (default `8rem`). Footer 7's link grid carries `data-footer-nav` and reads its column count from `--tome-footer-nav-cols` (default `3`). The defaults match the previous fixed values, so nothing changes until a theme sets them.
v0.11.0minor

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, normalized to a `tel:` link), `ctaLabel` (localized) and `ctaHref`; each action renders only when its fields are complete. `HeaderRenderer` mounts the bar beside the navbar, with no new props. It shows only below the active navbar's desktop breakpoint (`64em` for navbars 1, 3, 4, 5 and custom variants, `48em` for 2 and 6, `desktopBreakpoint` for 7), slides in once the page has scrolled past about the first viewport and hides again near the top, respects safe-area insets, adds its height to the body's bottom padding while visible (also published as `--tome-mobile-action-bar-offset`), and is `inert` while hidden. Under reduced motion it appears without the slide; without JavaScript it is always visible on phones. Theme hooks: `[data-tome-mobile-action-bar]`, `[data-action="call"]`, `[data-action="cta"]` and `--tome-mobile-action-bar-*` custom properties. New exports from `./header`: `MobileActionBar`, `resolveMobileActionBar`, `resolveHeaderDesktopBreakpointPx`, `toTelHref`; new type `TomeMobileActionBarData`.

  • 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, normalized to a `tel:` link), `ctaLabel` (localized) and `ctaHref`; each action renders only when its fields are complete. `HeaderRenderer` mounts the bar beside the navbar, with no new props. It shows only below the active navbar's desktop breakpoint (`64em` for navbars 1, 3, 4, 5 and custom variants, `48em` for 2 and 6, `desktopBreakpoint` for 7), slides in once the page has scrolled past about the first viewport and hides again near the top, respects safe-area insets, adds its height to the body's bottom padding while visible (also published as `--tome-mobile-action-bar-offset`), and is `inert` while hidden. Under reduced motion it appears without the slide; without JavaScript it is always visible on phones. Theme hooks: `[data-tome-mobile-action-bar]`, `[data-action="call"]`, `[data-action="cta"]` and `--tome-mobile-action-bar-*` custom properties. New exports from `./header`: `MobileActionBar`, `resolveMobileActionBar`, `resolveHeaderDesktopBreakpointPx`, `toTelHref`; new type `TomeMobileActionBarData`.
  • 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.
v0.10.1patch

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.
v0.10.0minor

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.
v0.9.9patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.9.8patch

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.
v0.9.7patch

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.
v0.9.6patch

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.
v0.9.5patch

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.
v0.9.4patch

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.
v0.9.3patch

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behavior change, and the published `dist/` is identical.
  • 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.
v0.9.2patch

0041ab0: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.

  • 0041ab0: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior 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.
v0.9.1patch

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.
v0.9.0minor

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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
v0.8.5patch

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.
v0.8.4patch

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.
v0.8.3patch

Footer variants now honor the same container tokens the navbars already expose: `--tome-chrome-content-max` and `--tome-chrome-content-inline`. Footers 1–9 and 11 previously hardcoded `max-width: 1200px` (1400px for footer9) and `padding-inline: 1rem`, so a consumer whose page grid is wider than 1200px could not align its footer without overriding CSS modules from outside the package — even though tome-ui's grid margin column and the navbar default share the identical `clamp(1rem, 4vw, 3rem)` formula. Each fallback preserves that variant's historical value, so a consumer setting neither token renders identically; this is opt-in alignment, not a visual change. footer10 has no `.container` rule and is untouched. Also: footer11's `.navCols` hardcoded three columns at `>= 48em`, orphaning any fourth nav group onto its own row — now `repeat(auto-fit, minmax(9rem, 1fr))`, which still resolves to three equal columns for three groups.

  • Footer variants now honor the same container tokens the navbars already expose: `--tome-chrome-content-max` and `--tome-chrome-content-inline`. Footers 1–9 and 11 previously hardcoded `max-width: 1200px` (1400px for footer9) and `padding-inline: 1rem`, so a consumer whose page grid is wider than 1200px could not align its footer without overriding CSS modules from outside the package — even though tome-ui's grid margin column and the navbar default share the identical `clamp(1rem, 4vw, 3rem)` formula. Each fallback preserves that variant's historical value, so a consumer setting neither token renders identically; this is opt-in alignment, not a visual change. footer10 has no `.container` rule and is untouched. Also: footer11's `.navCols` hardcoded three columns at `>= 48em`, orphaning any fourth nav group onto its own row — now `repeat(auto-fit, minmax(9rem, 1fr))`, which still resolves to three equal columns for three groups.
v0.8.2patch

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.
v0.8.1patch

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.
v0.8.0minor

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.
v0.7.0minor

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.
v0.6.2patch

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.
v0.6.1patch

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.
v0.4.0minor

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.
v0.3.0minor

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.
v0.2.1patch

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.
v0.2.0minor

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.
v0.1.20patch

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

Economy

v0.19.2
v0.19.2patch

5bfa1c8: `StripeAdapter` now records when a subscription's service actually ended. For a subscription canceled at period end, Stripe's `customer.subscription.deleted` carries `canceled_at` as the time the cancel was requested (often weeks earlier) and `ended_at` as the time the subscription ended, and the adapter read `canceled_at` first, so `subscription.canceled` reported the day notice was given as the day access ended. `accessEndsAt` now comes from `ended_at`, then `canceled_at`, then the event's own time, then now. Nothing changes for an immediate cancel, where both are the same instant, and the webhook handler still marks the subscription `canceled`, since `ended_at` is never in the future.

  • 5bfa1c8: `StripeAdapter` now records when a subscription's service actually ended. For a subscription canceled at period end, Stripe's `customer.subscription.deleted` carries `canceled_at` as the time the cancel was requested (often weeks earlier) and `ended_at` as the time the subscription ended, and the adapter read `canceled_at` first, so `subscription.canceled` reported the day notice was given as the day access ended. `accessEndsAt` now comes from `ended_at`, then `canceled_at`, then the event's own time, then now. Nothing changes for an immediate cancel, where both are the same instant, and the webhook handler still marks the subscription `canceled`, since `ended_at` is never in the future.
  • ec1a4b6: `StripeAdapter` no longer sends a 1970 date on a subscription event. A missing Stripe timestamp became the Unix epoch, and on Stripe API 2025-03-31.basil and later (including 2026-04-22.dahlia) a subscription has no top-level `current_period_end`, so a soft cancel without `cancel_at` arrived as `subscription.canceled` with access ending in 1970: a site's cancel handler ended a paying subscriber's service mid-period, and the webhook handler wrote `canceled` instead of `cancelAtPeriodEnd`. The soft cancel now reads `cancel_at`, then the subscription items' `current_period_end`, then the older top-level field, then retrieves the subscription once. If none gives a date, the update is not mapped and a warning is logged, and Stripe's `customer.subscription.deleted` completes the cancel when access ends. A `customer.subscription.deleted` without `canceled_at` or `ended_at` uses the event's own time, then now. The renewal period now comes from the invoice's subscription line. `invoice.paid` read `periodEnd` and `periodStart` off the first invoice line, and when that line was a one-off invoice item (a fee or a credit) the renewal carried the item's creation time as its period end, which consumers use to extend access and as an idempotency key. The adapter now takes the regular subscription line for the invoice's subscription on both current and older API shapes, before a proration line, and the earliest period end when there are several. An invoice whose lines do not identify themselves maps from line 0 exactly as before. An invoice with no period on any line reads it from the subscription. If that lookup fails for a reason that can pass (network, rate limit, a Stripe 5xx), the webhook now fails with 400 so Stripe redelivers it. If Stripe answers with no period, or says the subscription does not exist, the invoice is not mapped and is logged for review.
v0.19.1patch

4dd4495: The Stripe webhook handler no longer fails the renewal ledger write on a site that registers its own collection on the `subscriptions` slug. That collection has no `providerRef`, `metadata` or `member` field, so the Subscriptions lookup threw a query error on every `subscription.renewed`, no Payments row was written, and the renewal event went out without a `paymentId`. The handler now skips the Subscriptions find, update and create for a collection that is not economy's, still writes the Payments row (with no `subscription` link) and passes its id as `paymentId`. The canceled and payment-failed branches skip the same lookup instead of logging an error each time. Nothing changes for a site that uses economy's own Subscriptions collection.

  • 4dd4495: The Stripe webhook handler no longer fails the renewal ledger write on a site that registers its own collection on the `subscriptions` slug. That collection has no `providerRef`, `metadata` or `member` field, so the Subscriptions lookup threw a query error on every `subscription.renewed`, no Payments row was written, and the renewal event went out without a `paymentId`. The handler now skips the Subscriptions find, update and create for a collection that is not economy's, still writes the Payments row (with no `subscription` link) and passes its id as `paymentId`. The canceled and payment-failed branches skip the same lookup instead of logging an error each time. Nothing changes for a site that uses economy's own Subscriptions collection.
  • a79e08e: The renewal Payments row is now keyed by the invoice it records. Before, every `subscription.renewed` delivery created a row with the subscription id as `externalId`, so a redelivered `invoice.paid` wrote a second row with a new `paymentId`, and no row carried an invoice, PaymentIntent or charge id a refund could be matched by. The `subscription.renewed` event gains optional `invoiceId`, `paymentIntentRef` and `chargeRef`, and `StripeAdapter` fills them from `invoice.paid` (listing the invoice's payments when the payload does not carry them; a failed lookup leaves them out and the renewal still goes through). With `invoiceId`, the row uses it as `externalId`, stores the PaymentIntent and charge, and a redelivery of the same invoice reuses the row and its id as `paymentId`; this also applies on a site whose `subscriptions` slug is its own collection. An adapter that sends no `invoiceId` sees no change.
  • a79e08e: The Stripe webhook handler no longer marks a subscription `canceled` the moment a cancel at period end is requested. A soft cancel arrives as `subscription.canceled` with an `accessEndsAt` in the future (Stripe: `customer.subscription.updated` with `cancel_at_period_end`), and the handler wrote `status: 'canceled'` on the Subscriptions row anyway, after the cancel handlers had run, so it overwrote any status a handler wrote and showed a paid-up subscriber as canceled. The handler now sets `cancelAtPeriodEnd: true` and leaves `status` alone while `accessEndsAt` is more than a minute ahead, and sets `status: 'canceled'` when access has ended or no future date is given (Stripe: `customer.subscription.deleted`). The cancel event is dispatched exactly as before. Nothing changes for an immediate cancel, an Authorize.net cancel, or a site whose `subscriptions` slug is its own collection.
v0.19.0minor

e2f64c4: **Update your code first:** the commerce writers now store the American values. New rows hold `canceled` and `canceling`, so a site that compares to `'cancelled'` or `'cancelling'` must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values. Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store `canceled`. Entitlements canceling at period end store `canceling`, and the order overview's access status reports it. Restock requests store `canceled`, and a canceled shipment prefers `canceled` when the shipments collection offers it. Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step. On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.

  • e2f64c4: **Update your code first:** the commerce writers now store the American values. New rows hold `canceled` and `canceling`, so a site that compares to `'cancelled'` or `'cancelling'` must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values. Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store `canceled`. Entitlements canceling at period end store `canceling`, and the order overview's access status reports it. Restock requests store `canceled`, and a canceled shipment prefers `canceled` when the shipments collection offers it. Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step. On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.
v0.18.1patch

0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.

  • 0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.
v0.18.0minor

65020e0: **BREAKING:** the American spellings replace the British ones in the order-cancel and subscription-cancel APIs. - `OrderCancelledEvent` is now `OrderCanceledEvent`, and `SubscriptionCancelledEvent` is now `SubscriptionCanceledEvent`. - The order-cancel handler payload field `cancelledAt` is now `canceledAt`. - The stale-order sweeper summary fields `cancelled` and `cancelledOrderIds` are now `canceled` and `canceledOrderIds`. - The subscription webhook event type `'subscription.cancelled'` is now `'subscription.canceled'`. A custom adapter must emit the new value. - Stored values are unchanged: an order's `status` is still `'cancelled'`.

  • 65020e0: **BREAKING:** the American spellings replace the British ones in the order-cancel and subscription-cancel APIs. - `OrderCancelledEvent` is now `OrderCanceledEvent`, and `SubscriptionCancelledEvent` is now `SubscriptionCanceledEvent`. - The order-cancel handler payload field `cancelledAt` is now `canceledAt`. - The stale-order sweeper summary fields `cancelled` and `cancelledOrderIds` are now `canceled` and `canceledOrderIds`. - The subscription webhook event type `'subscription.cancelled'` is now `'subscription.canceled'`. A custom adapter must emit the new value. - Stored values are unchanged: an order's `status` is still `'cancelled'`.
  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.17.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.17.1patch

0f587ef: A subscription checkout now records the subscriber's email and customer id whichever order Stripe delivers its two webhooks in. When Stripe sent the first `invoice.paid` before `checkout.session.completed`, the invoice had already moved the Subscriptions row's `providerRef` from the checkout session id to the subscription id, so the completion could not find the row and a guest monthly gift was left with no `contactEmail` or `customerRef`. The completion now looks the row up by the session id (as before, one query in the usual order), then by the session's subscription id, then by `metadata.checkoutSessionId` for a site that stamps it. The `checkout.completed` webhook event gains an optional `subscriptionId` (Stripe: `session.subscription`, subscription-mode sessions only). Unchanged: the Order lookup still runs first, a `subscriptions` collection without `providerRef` is still skipped without being queried, an email or customer id already on the row is never overwritten, and a failure here still never fails the webhook.

  • 0f587ef: A subscription checkout now records the subscriber's email and customer id whichever order Stripe delivers its two webhooks in. When Stripe sent the first `invoice.paid` before `checkout.session.completed`, the invoice had already moved the Subscriptions row's `providerRef` from the checkout session id to the subscription id, so the completion could not find the row and a guest monthly gift was left with no `contactEmail` or `customerRef`. The completion now looks the row up by the session id (as before, one query in the usual order), then by the session's subscription id, then by `metadata.checkoutSessionId` for a site that stamps it. The `checkout.completed` webhook event gains an optional `subscriptionId` (Stripe: `session.subscription`, subscription-mode sessions only). Unchanged: the Order lookup still runs first, a `subscriptions` collection without `providerRef` is still skipped without being queried, an email or customer id already on the row is never overwritten, and a failure here still never fails the webhook.
v0.17.0minor

6f4c391: Single-product checkout now accepts a buyer-chosen amount (one-time, monthly or yearly) and a buyer without an account; no existing call changes. - **Custom amounts.** A Price opts in with `allowCustomAmount`, `minAmountCents` and `maxAmountCents` (validated on save), plus an optional `externalProductId`. `createCheckoutSessionAction` and `createSubscriptionCheckoutAction` take `priceId`, `amountCents`, `coverFees` and `expectedTotalCents`, and re-check every number on the server against the Price: the amount and its bounds, the currency, the fee cover and the total the buyer was shown. A custom amount is charged through an inline price, with `recurring` for monthly and yearly gifts, so no Stripe Price is needed. - **Guest checkout.** A `guest` buyer replaces `memberId`/`memberEmail`. One-time guest orders point at the site's placeholder member (`guestCustomerId`) and record the buyer as `contactEmail`; recurring guest subscriptions have no member and a unique `guest:<uuid>` `userId`. Only product types listed in `guestProductTypes` can be bought as a guest. A failed guest session cancels its pending Order at once. - **Fee cover** is a plain percentage plus an optional fixed part (`feeCover: { percentBps, fixedCents? }`), off unless the caller passes `coverFees: true`. - **New helpers.** `ensureGuestMember` finds or creates the placeholder member. `claimGuestOrders` moves a verified buyer's past guest orders and subscriptions to their new member. `@wabbit/tome-economy/giving` exports the side-effect-free rules (`computeFeeCoverCents`, `validateGiftAmount`, `isGuestUserId`) for forms. - **Webhooks.** `checkout.completed` gains `mode`, `customerEmail` and `customerRef`. A guest order with no email gets the one the buyer paid with. Every completion still looks for its Order first, in every session mode, so a subscription-mode session that has an Order completes exactly as before; only when there is none does a subscription-mode completion record the email and customer id on its Subscriptions row instead of logging a missing Order. That record is skipped when the configured subscriptions collection has no `providerRef` field, and its errors never affect the webhook. The order-complete event gains `buyerKind: 'guest'` for guest orders. - **Adapters.** `StripeAdapter` sends `submit_type: 'donate'` for one-time and subscription gifts, `price_data.product` when a provider product is given, and inline recurring prices; it declares the new `supportsInlineRecurringPrice`. For custom adapter authors: `CreateCheckoutSessionArgs.customerEmail` and `CreateSubscriptionSessionArgs.customerEmail`/`stripePriceId` are now optional at the type level (still always sent on every pre-existing path). - **Schema.** Prices gain four optional fields and Subscriptions gain `contactEmail`. Postgres consumers generate and run a migration; MongoDB consumers need none. Subscription lifecycle handlers that grant access should skip `userId`s for which `isGuestUserId` is true.

  • 6f4c391: Single-product checkout now accepts a buyer-chosen amount (one-time, monthly or yearly) and a buyer without an account; no existing call changes. - **Custom amounts.** A Price opts in with `allowCustomAmount`, `minAmountCents` and `maxAmountCents` (validated on save), plus an optional `externalProductId`. `createCheckoutSessionAction` and `createSubscriptionCheckoutAction` take `priceId`, `amountCents`, `coverFees` and `expectedTotalCents`, and re-check every number on the server against the Price: the amount and its bounds, the currency, the fee cover and the total the buyer was shown. A custom amount is charged through an inline price, with `recurring` for monthly and yearly gifts, so no Stripe Price is needed. - **Guest checkout.** A `guest` buyer replaces `memberId`/`memberEmail`. One-time guest orders point at the site's placeholder member (`guestCustomerId`) and record the buyer as `contactEmail`; recurring guest subscriptions have no member and a unique `guest:<uuid>` `userId`. Only product types listed in `guestProductTypes` can be bought as a guest. A failed guest session cancels its pending Order at once. - **Fee cover** is a plain percentage plus an optional fixed part (`feeCover: { percentBps, fixedCents? }`), off unless the caller passes `coverFees: true`. - **New helpers.** `ensureGuestMember` finds or creates the placeholder member. `claimGuestOrders` moves a verified buyer's past guest orders and subscriptions to their new member. `@wabbit/tome-economy/giving` exports the side-effect-free rules (`computeFeeCoverCents`, `validateGiftAmount`, `isGuestUserId`) for forms. - **Webhooks.** `checkout.completed` gains `mode`, `customerEmail` and `customerRef`. A guest order with no email gets the one the buyer paid with. Every completion still looks for its Order first, in every session mode, so a subscription-mode session that has an Order completes exactly as before; only when there is none does a subscription-mode completion record the email and customer id on its Subscriptions row instead of logging a missing Order. That record is skipped when the configured subscriptions collection has no `providerRef` field, and its errors never affect the webhook. The order-complete event gains `buyerKind: 'guest'` for guest orders. - **Adapters.** `StripeAdapter` sends `submit_type: 'donate'` for one-time and subscription gifts, `price_data.product` when a provider product is given, and inline recurring prices; it declares the new `supportsInlineRecurringPrice`. For custom adapter authors: `CreateCheckoutSessionArgs.customerEmail` and `CreateSubscriptionSessionArgs.customerEmail`/`stripePriceId` are now optional at the type level (still always sent on every pre-existing path). - **Schema.** Prices gain four optional fields and Subscriptions gain `contactEmail`. Postgres consumers generate and run a migration; MongoDB consumers need none. Subscription lifecycle handlers that grant access should skip `userId`s for which `isGuestUserId` is true.
v0.16.0minor

0e5f063: **BREAKING:** A full refund now revokes the access the order granted by default, and the Orders default edit view becomes the Order Overview. **Migration:** Pass `refundAccess: false` to `createEconomyLayer` / `createOrdersCollection` to keep refunds from touching access, and `admin: { overview: false }` to keep the previous Orders edit view. SQL adapters generate a migration (on Postgres: the `orders_refunds` array table and new `payments` columns); MongoDB needs none. Run `backfillOrderRefunds` from `@wabbit/tome-economy/scripts` once per consumer (dry run by default). - Orders gain a server-written `refunds[]` array, one row per provider refund or admin refund edit: `providerRefundId`, `amountCents`, `currency`, `refundedAt`, `source`, `refundedBy`, `reason`, `note`, `sourceEventId`, `accessResult`. - The webhook upserts rows by refund id in the same update that records the replay keys; an admin raising `refundedCents` appends an admin row with the acting user; admins can edit only `reason` and `note`. - `StripeAdapter` reads `refunds.list({ charge })` on `charge.refunded` (Stripe's `expired_uncaptured_charge` maps to `other`; a list failure leaves the cumulative path running). - On checkout completion `StripeAdapter` retrieves the PaymentIntent once to fill the new Payments fields `paymentIntentRef`, `chargeRef`, `card` (brand, last4, expiry, wallet; never the number or fingerprint) and `livemode`. - Refund access policy, configured by `refundAccess`: a full refund revokes the entitlements the order granted (writing the catalog revocation audit fields by slug) and calls each product type's `revokeAccess` hook, unless another order still justifies the access. Partial refunds change nothing. `exceptProductTypes` and `isStillJustified` tune it. - The policy runs before the order-refunded dispatch, which now carries `actorId` and `refunds`. - `admin.overview` (default true) installs the Order Overview as the Orders default edit view; `@wabbit/tome-economy/admin` ships `orderOverviewViewConfig` and `OrderOverviewView`. - `react` and `@payloadcms/ui` are new optional peer dependencies.

  • 0e5f063: **BREAKING:** A full refund now revokes the access the order granted by default, and the Orders default edit view becomes the Order Overview. **Migration:** Pass `refundAccess: false` to `createEconomyLayer` / `createOrdersCollection` to keep refunds from touching access, and `admin: { overview: false }` to keep the previous Orders edit view. SQL adapters generate a migration (on Postgres: the `orders_refunds` array table and new `payments` columns); MongoDB needs none. Run `backfillOrderRefunds` from `@wabbit/tome-economy/scripts` once per consumer (dry run by default). - Orders gain a server-written `refunds[]` array, one row per provider refund or admin refund edit: `providerRefundId`, `amountCents`, `currency`, `refundedAt`, `source`, `refundedBy`, `reason`, `note`, `sourceEventId`, `accessResult`. - The webhook upserts rows by refund id in the same update that records the replay keys; an admin raising `refundedCents` appends an admin row with the acting user; admins can edit only `reason` and `note`. - `StripeAdapter` reads `refunds.list({ charge })` on `charge.refunded` (Stripe's `expired_uncaptured_charge` maps to `other`; a list failure leaves the cumulative path running). - On checkout completion `StripeAdapter` retrieves the PaymentIntent once to fill the new Payments fields `paymentIntentRef`, `chargeRef`, `card` (brand, last4, expiry, wallet; never the number or fingerprint) and `livemode`. - Refund access policy, configured by `refundAccess`: a full refund revokes the entitlements the order granted (writing the catalog revocation audit fields by slug) and calls each product type's `revokeAccess` hook, unless another order still justifies the access. Partial refunds change nothing. `exceptProductTypes` and `isStillJustified` tune it. - The policy runs before the order-refunded dispatch, which now carries `actorId` and `refunds`. - `admin.overview` (default true) installs the Order Overview as the Orders default edit view; `@wabbit/tome-economy/admin` ships `orderOverviewViewConfig` and `OrderOverviewView`. - `react` and `@payloadcms/ui` are new optional peer dependencies.
v0.15.1patch

e6d044d: Failed payment-provider calls now log only the error type, HTTP status, request id and Stripe error codes, never the provider's message, headers or raw error. - Stripe's `code` and `decline_code` (for example `card_declined`, `insufficient_funds`) are kept, as `code` and `declineCode`, so a failed payment can still be diagnosed from the log. - Covers the checkout and cart-checkout actions, the subscription checkout action, the Stripe and payout webhook handlers, the refund ledger, the order and subscription event buses and the pending-order sweeper. - A Stripe authentication error used to put a masked key fragment (`sk_test_****…`) into the log line through its message. That text is no longer logged. - Internal failures, such as a database write or a throwing event handler, still log their message and stack. An internal error that wraps a provider error, or whose text contains a key-shaped string, is reduced or redacted the same way. - Nothing else changes: the same errors are thrown, the same responses returned and the same records written.

  • e6d044d: Failed payment-provider calls now log only the error type, HTTP status, request id and Stripe error codes, never the provider's message, headers or raw error. - Stripe's `code` and `decline_code` (for example `card_declined`, `insufficient_funds`) are kept, as `code` and `declineCode`, so a failed payment can still be diagnosed from the log. - Covers the checkout and cart-checkout actions, the subscription checkout action, the Stripe and payout webhook handlers, the refund ledger, the order and subscription event buses and the pending-order sweeper. - A Stripe authentication error used to put a masked key fragment (`sk_test_****…`) into the log line through its message. That text is no longer logged. - Internal failures, such as a database write or a throwing event handler, still log their message and stack. An internal error that wraps a provider error, or whose text contains a key-shaped string, is reduced or redacted the same way. - Nothing else changes: the same errors are thrown, the same responses returned and the same records written.
v0.15.0minor

59d5804: Adds `createCartCheckoutAction`, a parallel multi-line checkout for physical goods; `createCheckoutSessionAction` behaves as before for every existing caller. All additive and optional: - **Cart checkout.** `createCartCheckoutAction` turns N cart lines into N provider line items, one pending Order and one hosted session with a fixed shipping option. Every line's price is resolved server-side from the newest active one-time Price for `(product, variantSku)`; the caller sends identities and quantities, never amounts. A member buyer passes the same identity gate as the single-item action; a guest buyer is recorded against a configured placeholder member (`guestCustomerId`) with `contactEmail`. An optional `inventory` guard reserves stock after the Order is written, and a failed reservation or session cancels the Order at once. Promotion codes pass through. - **Variant guard.** `createCheckoutSessionAction` throws the new `VariantPriceRequiredError`, before writing anything, when the Price it resolves names a `variantSku`. Products whose Prices carry no `variantSku` resolve exactly as before. Not marked BREAKING: a Price only carries a `variantSku` to sell a variant through the cart, which did not exist before this release, so no existing single-item checkout can reach the guard. - **Fields.** Orders gain optional `shippingCents` (default 0), `contactEmail` and `items[].variantSku`; Prices gain an optional display-only `compareAtCents`. Postgres consumers generate and run a migration for the new columns; MongoDB consumers need none. - **Provider seam.** `CheckoutLineItem` gains `displayName?`/`imageUrl?`; `CreateCheckoutSessionArgs` gains `shippingAddressCollection?`, `shippingOptions?` and `expiresAt?`; `PaymentProviderAdapter` gains `supportsCartShipping?` and `getCheckoutSession?`. `StripeAdapter` implements both and sends each new Stripe parameter only when its argument is present. - **Webhooks.** `checkout.completed` carries `shippingCents?`, written onto the Order only when it is non-zero or the Order already holds a quote. A new `checkout.expired` event is acknowledged and ignored unless `cancelOnExpiry` is set (`true`, or a list of `metadata.kind` values); only `pending` Orders are ever canceled. The completion step is exported as `completeCheckoutOrder`. - **Order-cancel bus.** `registerOrderCancelHandler` / `onOrderCancelDispatch` fire once when an Order enters `cancelled`; with no handler registered the dispatch is silent. - **Pending-order sweeper.** `expireStalePendingOrders` (and `createExpireStalePendingOrdersTask`) settles or cancels stale pending Orders of the `metadata.kind` values a consumer names. Economy registers nothing; Orders of unnamed kinds are never touched.

  • 59d5804: Adds `createCartCheckoutAction`, a parallel multi-line checkout for physical goods; `createCheckoutSessionAction` behaves as before for every existing caller. All additive and optional: - **Cart checkout.** `createCartCheckoutAction` turns N cart lines into N provider line items, one pending Order and one hosted session with a fixed shipping option. Every line's price is resolved server-side from the newest active one-time Price for `(product, variantSku)`; the caller sends identities and quantities, never amounts. A member buyer passes the same identity gate as the single-item action; a guest buyer is recorded against a configured placeholder member (`guestCustomerId`) with `contactEmail`. An optional `inventory` guard reserves stock after the Order is written, and a failed reservation or session cancels the Order at once. Promotion codes pass through. - **Variant guard.** `createCheckoutSessionAction` throws the new `VariantPriceRequiredError`, before writing anything, when the Price it resolves names a `variantSku`. Products whose Prices carry no `variantSku` resolve exactly as before. Not marked BREAKING: a Price only carries a `variantSku` to sell a variant through the cart, which did not exist before this release, so no existing single-item checkout can reach the guard. - **Fields.** Orders gain optional `shippingCents` (default 0), `contactEmail` and `items[].variantSku`; Prices gain an optional display-only `compareAtCents`. Postgres consumers generate and run a migration for the new columns; MongoDB consumers need none. - **Provider seam.** `CheckoutLineItem` gains `displayName?`/`imageUrl?`; `CreateCheckoutSessionArgs` gains `shippingAddressCollection?`, `shippingOptions?` and `expiresAt?`; `PaymentProviderAdapter` gains `supportsCartShipping?` and `getCheckoutSession?`. `StripeAdapter` implements both and sends each new Stripe parameter only when its argument is present. - **Webhooks.** `checkout.completed` carries `shippingCents?`, written onto the Order only when it is non-zero or the Order already holds a quote. A new `checkout.expired` event is acknowledged and ignored unless `cancelOnExpiry` is set (`true`, or a list of `metadata.kind` values); only `pending` Orders are ever canceled. The completion step is exported as `completeCheckoutOrder`. - **Order-cancel bus.** `registerOrderCancelHandler` / `onOrderCancelDispatch` fire once when an Order enters `cancelled`; with no handler registered the dispatch is silent. - **Pending-order sweeper.** `expireStalePendingOrders` (and `createExpireStalePendingOrdersTask`) settles or cancels stale pending Orders of the `metadata.kind` values a consumer names. Economy registers nothing; Orders of unnamed kinds are never touched.
v0.14.0minor

302d53a: **Refund accounting is cumulative and replay-safe; checkout completion is idempotent; the order-refunded event carries what a consumer needs to act on it.** 1. **A replayed partial refund, or a second partial, no longer marks the Order fully refunded.** The refund branch of `createStripeWebhookHandler` decided full-or-partial by whether a `succeeded` Payment still existed, and marked that Payment `refunded` on any refund, so the second refund event for an order (a redelivery of the first, or a genuine second partial) always read as full. It now decides by amount: the Order's `refundedCents` takes the provider's cumulative refunded amount (Stripe's charge `amount_refunded`, the larger of what is recorded and what the event reports, never a running sum), and the Order is `refunded` when that reaches or passes `totalCents`, `partially-refunded` when it is above 0 and below. Each applied event id is recorded on the Order, and an event already applied, or one reporting no more than the recorded amount, writes nothing. Status only moves forward (`refunded` stays `refunded`). An event with no refund amount now writes nothing and logs a warning; it used to set a refund status with 0 refunded. 2. **A `checkout.completed` replayed after a refund no longer re-grants.** For an Order already `completed`, `refunded` or `partially-refunded` the branch writes nothing: no status change, no second Payment, no order-complete dispatch, and the `onOrderComplete` option does not run. For a `pending` Order, a Payment already recorded for the same session or Order is reused instead of duplicated. 3. **`OrderRefundedEvent` gains `previousStatus`, `newStatus`, `previousRefundedCents`, `full`, `sourceEventId` and `order`** (the six existing fields are unchanged). `registerOrderRefundHandler` keeps its signature; `onOrderRefundDispatch(order, transition?)` takes an optional second argument (new `OrderRefundTransition` type). A refund handler that throws synchronously is now caught and logged like a rejecting one, and a missing `Order.customer` dispatches `userId: ''` instead of throwing. 4. **`WebhookEvent`'s `refund` member gains three optional fields:** `eventId` (the provider event id; `StripeAdapter` now sets it from the Stripe event), `refundAmountBasis: 'cumulative' | 'delta'` (omitted = cumulative, the previous reading) and `refundId`. A delta event's amount is added to the Order's total, replay-guarded by `eventId` and `refundId`. **Schema changes — Postgres consumers must generate and run a migration before deploying** (`payload migrate:create`, then `payload migrate`): `orders.refundEventIds` (json), `payments.refundedCents` (number, default 0), and a new `partially-refunded` value on the Payments `status` select (an enum change). MongoDB needs nothing. `PaymentStatus` gains `'partially-refunded'`; code that switches exhaustively on it needs the new case. A partial refund now leaves its Payment `partially-refunded`, where it used to become `refunded`. **Consumer follow-up:** a site that watched the Orders collection in its own `afterChange` to react to refunds can move that logic to `registerOrderRefundHandler` and branch on `event.full`. `@wabbit/tome-economy-authnet`: `mapAuthorizeNetWebhookEvent` marks `net.authorize.payment.refund.created` as `refundAmountBasis: 'delta'` (an Authorize.net refund transaction carries that one refund's amount, not a running total) and adds `eventId` (the `notificationId`) and `refundId` (the refund `transId`). With this economy release, two partial refunds on an Authorize.net order now sum, and a redelivered notification is ignored. Upgrade both packages together: an older economy ignores the new fields and keeps its previous behavior, and this economy reading an older authnet's events treats each refund amount as a running total.

  • 302d53a: **Refund accounting is cumulative and replay-safe; checkout completion is idempotent; the order-refunded event carries what a consumer needs to act on it.** 1. **A replayed partial refund, or a second partial, no longer marks the Order fully refunded.** The refund branch of `createStripeWebhookHandler` decided full-or-partial by whether a `succeeded` Payment still existed, and marked that Payment `refunded` on any refund, so the second refund event for an order (a redelivery of the first, or a genuine second partial) always read as full. It now decides by amount: the Order's `refundedCents` takes the provider's cumulative refunded amount (Stripe's charge `amount_refunded`, the larger of what is recorded and what the event reports, never a running sum), and the Order is `refunded` when that reaches or passes `totalCents`, `partially-refunded` when it is above 0 and below. Each applied event id is recorded on the Order, and an event already applied, or one reporting no more than the recorded amount, writes nothing. Status only moves forward (`refunded` stays `refunded`). An event with no refund amount now writes nothing and logs a warning; it used to set a refund status with 0 refunded. 2. **A `checkout.completed` replayed after a refund no longer re-grants.** For an Order already `completed`, `refunded` or `partially-refunded` the branch writes nothing: no status change, no second Payment, no order-complete dispatch, and the `onOrderComplete` option does not run. For a `pending` Order, a Payment already recorded for the same session or Order is reused instead of duplicated. 3. **`OrderRefundedEvent` gains `previousStatus`, `newStatus`, `previousRefundedCents`, `full`, `sourceEventId` and `order`** (the six existing fields are unchanged). `registerOrderRefundHandler` keeps its signature; `onOrderRefundDispatch(order, transition?)` takes an optional second argument (new `OrderRefundTransition` type). A refund handler that throws synchronously is now caught and logged like a rejecting one, and a missing `Order.customer` dispatches `userId: ''` instead of throwing. 4. **`WebhookEvent`'s `refund` member gains three optional fields:** `eventId` (the provider event id; `StripeAdapter` now sets it from the Stripe event), `refundAmountBasis: 'cumulative' | 'delta'` (omitted = cumulative, the previous reading) and `refundId`. A delta event's amount is added to the Order's total, replay-guarded by `eventId` and `refundId`. **Schema changes — Postgres consumers must generate and run a migration before deploying** (`payload migrate:create`, then `payload migrate`): `orders.refundEventIds` (json), `payments.refundedCents` (number, default 0), and a new `partially-refunded` value on the Payments `status` select (an enum change). MongoDB needs nothing. `PaymentStatus` gains `'partially-refunded'`; code that switches exhaustively on it needs the new case. A partial refund now leaves its Payment `partially-refunded`, where it used to become `refunded`. **Consumer follow-up:** a site that watched the Orders collection in its own `afterChange` to react to refunds can move that logic to `registerOrderRefundHandler` and branch on `event.full`. `@wabbit/tome-economy-authnet`: `mapAuthorizeNetWebhookEvent` marks `net.authorize.payment.refund.created` as `refundAmountBasis: 'delta'` (an Authorize.net refund transaction carries that one refund's amount, not a running total) and adds `eventId` (the `notificationId`) and `refundId` (the refund `transId`). With this economy release, two partial refunds on an Authorize.net order now sum, and a redelivered notification is ignored. Upgrade both packages together: an older economy ignores the new fields and keeps its previous behavior, and this economy reading an older authnet's events treats each refund amount as a running total.
v0.13.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.13.1patch

c71de23: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `ShoppingCart` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.

  • c71de23: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `ShoppingCart` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
v0.13.0minor

b2470a2: **BREAKING:** economy's collection admin gate now delegates to core's `sessionHasCapabilityOrLegacyAdmin(req, 'economy:admin')` (core has been a required peer since 2026-09-06). The default access of Orders, Payments, Prices, Subscriptions, VendorEarnings, PayoutAccounts and Payouts therefore also admits `super-admin` (populated roles, `role: string[]`, or the legacy `'superadmin'`/`'super-admin'` strings) and any role granted the `economy:admin` capability — previously only a legacy `roles: string[]` containing `'admin'` passed. Consumers who override `access` are unaffected. Internally the gate is async and takes the request. All 51 inline `as CollectionSlug` casts are replaced with core's `typedSlug()` (no runtime change).

  • b2470a2: **BREAKING:** economy's collection admin gate now delegates to core's `sessionHasCapabilityOrLegacyAdmin(req, 'economy:admin')` (core has been a required peer since 2026-09-06). The default access of Orders, Payments, Prices, Subscriptions, VendorEarnings, PayoutAccounts and Payouts therefore also admits `super-admin` (populated roles, `role: string[]`, or the legacy `'superadmin'`/`'super-admin'` strings) and any role granted the `economy:admin` capability — previously only a legacy `roles: string[]` containing `'admin'` passed. Consumers who override `access` are unaffected. Internally the gate is async and takes the request. All 51 inline `as CollectionSlug` casts are replaced with core's `typedSlug()` (no runtime change).
  • 56686d6: Stripe webhook handler: the "setup.completed with no hook registered" and "subscription.payment_failed (dunning)" notices log through `payload.logger.info` instead of `console.info`.
  • 44b39f3: `runPayoutBatch` reads relationship ids with core's `relationId` instead of a local copy, which also handles numeric (Postgres) ids.
v0.12.0minor

40a0f22: Handler registration accepts an optional `key`, so a module evaluated in more than one bundle registers exactly one handler. Every economy dispatcher keeps its handler list on `globalThis` under a `Symbol.for(...)` key, because bundlers — notably Next.js — load the module in more than one server bundle. That is what stops registration writing into one copy while dispatch reads an empty other copy. The unaddressed half is that registration then also runs once per bundle, and a bare `push()` stacked a second copy of the same handler each time. The dispatchers fan out through `Promise.allSettled`, so the copies run concurrently, and any consumer handler doing a check-then-act (find, then create if absent) had every copy pass the check and write. The failure surfaces as a silent duplicate rather than an error. `registerOrderCompleteHandler`, `registerOrderRefundHandler`, and the four subscription lifecycle registrations now take an optional second argument. Passing `{ key: 'my-app/grants' }` replaces whatever was previously registered under that key instead of appending beside it, which also does the right thing under HMR — the newest closure wins. Omitting the key preserves the previous append-every-time behavior exactly, so no existing caller changes semantics. Two implementation details are deliberate. The handler list keeps holding bare handler functions, with the key map stored under its own symbol, because `Symbol.for` keys are process-global and an older copy of this package loaded as a nested dependency must still be able to dispatch the same list. And an unsubscribe returned by a superseded registration is a no-op rather than tearing down its replacement, which matters for callers like `@wabbit/tome-affiliates` that bundle several unsubscribes together. Found from a live double-grant: a consumer site granted two entitlement rows for one purchase on 2026-09-20 because its consumer-side guard against re-registering was a module-local `let` while the registry it guarded was global. `@wabbit/tome-affiliates` avoids the same trap only by hand-rolling its own globalThis-keyed entry around the register calls; the `key` option is that pattern made available to every consumer.

  • 40a0f22: Handler registration accepts an optional `key`, so a module evaluated in more than one bundle registers exactly one handler. Every economy dispatcher keeps its handler list on `globalThis` under a `Symbol.for(...)` key, because bundlers — notably Next.js — load the module in more than one server bundle. That is what stops registration writing into one copy while dispatch reads an empty other copy. The unaddressed half is that registration then also runs once per bundle, and a bare `push()` stacked a second copy of the same handler each time. The dispatchers fan out through `Promise.allSettled`, so the copies run concurrently, and any consumer handler doing a check-then-act (find, then create if absent) had every copy pass the check and write. The failure surfaces as a silent duplicate rather than an error. `registerOrderCompleteHandler`, `registerOrderRefundHandler`, and the four subscription lifecycle registrations now take an optional second argument. Passing `{ key: 'my-app/grants' }` replaces whatever was previously registered under that key instead of appending beside it, which also does the right thing under HMR — the newest closure wins. Omitting the key preserves the previous append-every-time behavior exactly, so no existing caller changes semantics. Two implementation details are deliberate. The handler list keeps holding bare handler functions, with the key map stored under its own symbol, because `Symbol.for` keys are process-global and an older copy of this package loaded as a nested dependency must still be able to dispatch the same list. And an unsubscribe returned by a superseded registration is a no-op rather than tearing down its replacement, which matters for callers like `@wabbit/tome-affiliates` that bundle several unsubscribes together. Found from a live double-grant: a consumer site granted two entitlement rows for one purchase on 2026-09-20 because its consumer-side guard against re-registering was a module-local `let` while the registry it guarded was global. `@wabbit/tome-affiliates` avoids the same trap only by hand-rolling its own globalThis-keyed entry around the register calls; the `key` option is that pattern made available to every consumer.
v0.11.0minor

1553fde: **Four additive economy seams — the prerequisite PRs the `@wabbit/tome-affiliates` layer spec (2026-09-14, §9 a-d) needs to build on.** 1. **Checkout metadata passthrough.** `CheckoutSessionInput` gains `metadata?: Record<string, string>`. Reserved keys `orderId`, `productId`, `memberId`, `productType` (exported as `RESERVED_CHECKOUT_METADATA_KEYS`) are computed by the action itself; a caller-supplied key that collides with one now throws the new `ReservedCheckoutMetadataKeyError` before any Payload or adapter call, instead of silently colliding. The merged metadata reaches the adapter's session metadata (and so `payment_intent_data.metadata`) AND the pending Order's own `metadata` field, written at Order-creation time. `createSubscriptionCheckoutAction`'s `metadata` was already required and already passed arbitrary extra keys through, so it needed no change for the same affiliate-attribution use case. 2. **Paid-amount truth on Orders.** `Orders.totalCents`/`discountCents` were frozen at list price from `createCheckoutSessionAction`'s pending-Order write (`discountCents` hardcoded `0`) and never corrected once Stripe applied a promo. `createStripeWebhookHandler`'s `checkout.completed` branch now overwrites `totalCents` with the session's real `amount_total` on completion, plus two NEW Orders fields written when the session resolves them: `taxCents` (from `total_details.amount_tax`) and `promotionCodeRef` (the applied Stripe promotion code id, from `session.discounts[0].promotion_code`). `discountCents` is likewise overwritten from `total_details.amount_discount` when resolvable. This is also the fix for the refund branch's `isPartial` comparison, which reads the same `order.totalCents` field — no separate code change was needed there once it carries the real charged amount instead of list price. `WebhookEvent`'s `checkout.completed` member gains `subtotalCents`/`discountCents`/`taxCents`/`promotionCodeRef`, all optional (undefined on a hand-built event that doesn't carry them; every real Stripe session does). 3. **Order refund dispatcher.** NEW event bus mirroring the order-complete one exactly: `registerOrderRefundHandler(handler)`, `onOrderRefundDispatch(order)`, event shape `OrderRefundedEvent { orderId, userId, status: 'refunded' | 'partially-refunded', refundedCents, totalCents, refundedAt }`. No layer previously had a refund signal from economy; consumers reinvented their own `afterChange` watch. NEW Orders field `refundedCents` (default `0`) carries the provider's cumulative refund amount, written by the webhook handler's refund branch. The Orders `afterChange` hook dispatches when status is `refunded`/`partially-refunded` AND (the status just transitioned OR `refundedCents` increased) — the second arm makes a partial-then-full refund dispatch twice; the first arm alone makes a webhook redelivery reporting the same cumulative amount a no-op. 4. **Renewal amount on the renewal event.** `SubscriptionRenewedEvent` gains `amountCents?`, `currency?`, `paymentId?`. The webhook handler now writes the renewal Payment row BEFORE dispatching (previously: dispatch first, write after) so the dispatch can carry that row's id. `amountCents`/`currency` come straight from the source `WebhookEvent` and so are present whenever it resolved them, independent of the ledger write's own success; only `paymentId` is omitted if that write fails — the entitlement dispatch itself is never gated on it, preserving the existing "a ledger-write hiccup cannot turn a successful grant into a Stripe retry" guarantee. Consumer follow-up: none required — every change here is additive, and `OrderCompletedEvent` is unchanged (frozen contract, shared-contracts-v3 §4).

  • 1553fde: **Four additive economy seams — the prerequisite PRs the `@wabbit/tome-affiliates` layer spec (2026-09-14, §9 a-d) needs to build on.** 1. **Checkout metadata passthrough.** `CheckoutSessionInput` gains `metadata?: Record<string, string>`. Reserved keys `orderId`, `productId`, `memberId`, `productType` (exported as `RESERVED_CHECKOUT_METADATA_KEYS`) are computed by the action itself; a caller-supplied key that collides with one now throws the new `ReservedCheckoutMetadataKeyError` before any Payload or adapter call, instead of silently colliding. The merged metadata reaches the adapter's session metadata (and so `payment_intent_data.metadata`) AND the pending Order's own `metadata` field, written at Order-creation time. `createSubscriptionCheckoutAction`'s `metadata` was already required and already passed arbitrary extra keys through, so it needed no change for the same affiliate-attribution use case. 2. **Paid-amount truth on Orders.** `Orders.totalCents`/`discountCents` were frozen at list price from `createCheckoutSessionAction`'s pending-Order write (`discountCents` hardcoded `0`) and never corrected once Stripe applied a promo. `createStripeWebhookHandler`'s `checkout.completed` branch now overwrites `totalCents` with the session's real `amount_total` on completion, plus two NEW Orders fields written when the session resolves them: `taxCents` (from `total_details.amount_tax`) and `promotionCodeRef` (the applied Stripe promotion code id, from `session.discounts[0].promotion_code`). `discountCents` is likewise overwritten from `total_details.amount_discount` when resolvable. This is also the fix for the refund branch's `isPartial` comparison, which reads the same `order.totalCents` field — no separate code change was needed there once it carries the real charged amount instead of list price. `WebhookEvent`'s `checkout.completed` member gains `subtotalCents`/`discountCents`/`taxCents`/`promotionCodeRef`, all optional (undefined on a hand-built event that doesn't carry them; every real Stripe session does). 3. **Order refund dispatcher.** NEW event bus mirroring the order-complete one exactly: `registerOrderRefundHandler(handler)`, `onOrderRefundDispatch(order)`, event shape `OrderRefundedEvent { orderId, userId, status: 'refunded' | 'partially-refunded', refundedCents, totalCents, refundedAt }`. No layer previously had a refund signal from economy; consumers reinvented their own `afterChange` watch. NEW Orders field `refundedCents` (default `0`) carries the provider's cumulative refund amount, written by the webhook handler's refund branch. The Orders `afterChange` hook dispatches when status is `refunded`/`partially-refunded` AND (the status just transitioned OR `refundedCents` increased) — the second arm makes a partial-then-full refund dispatch twice; the first arm alone makes a webhook redelivery reporting the same cumulative amount a no-op. 4. **Renewal amount on the renewal event.** `SubscriptionRenewedEvent` gains `amountCents?`, `currency?`, `paymentId?`. The webhook handler now writes the renewal Payment row BEFORE dispatching (previously: dispatch first, write after) so the dispatch can carry that row's id. `amountCents`/`currency` come straight from the source `WebhookEvent` and so are present whenever it resolved them, independent of the ledger write's own success; only `paymentId` is omitted if that write fails — the entitlement dispatch itself is never gated on it, preserving the existing "a ledger-write hiccup cannot turn a successful grant into a Stripe retry" guarantee. Consumer follow-up: none required — every change here is additive, and `OrderCompletedEvent` is unchanged (frozen contract, shared-contracts-v3 §4).
  • 0c715fc: **Payouts extension — the provider-neutral payout seam the `@wabbit/tome-affiliates` layer spec (2026-09-14, §9 e) needs, generalized from the April vendor Payouts design.** Opt in with `createEconomyLayer({ payouts: true })` (default off; existing consumers are unaffected). Adds the `payout-accounts` and `payouts` collections with GDPR registration (`redact` and `retain`), a `PayoutProviderAdapter` interface with `ManualPayoutAdapter` and `StripeConnectPayoutAdapter` (v1 Connect account with Express dashboard and transfers capability, account-link onboarding, separate transfers from the platform balance with a request idempotency key, balance lookup, `account.updated` and `transfer.reversed` webhook mapping), `createPayoutWebhookHandler`, `runPayoutBatch` (released payouts first, per-payee minimum, waiting-payout guard, balance check before any write, first-payout review, failed attempts free their key for a later retry), and `getPayeeAnnualTotals`.
v0.10.1patch

e5c4acb: **Two webhook defects found by the first live purchase-and-refund on a consumer site (2026-09-13). Both affect every consumer of `createStripeWebhookHandler`.** 1. **Refunds never resolved their Order.** `StripeAdapter.handleWebhook` mapped `charge.refunded` to `orderId = charge.metadata.orderId`, but one-time Checkout stamps metadata on the Session only and Stripe never copies it to the PaymentIntent or Charge. Every refund therefore arrived with `orderId: ''`, the handler skipped the Payment and Order writes, returned 200, and the consumer's refund-revocation hooks (keyed on `status: 'refunded'`) never fired — a refunded buyer kept the entitlement. Fix, in the adapter: `createCheckoutSession` now stamps `payment_intent_data.metadata` (the payment-mode twin of the `subscription_data.metadata` mirror the subscription path always had), and the refund mapping resolves the order from the charge's own metadata, then the PaymentIntent's metadata, then the Checkout Session listed by `payment_intent` — so charges from sessions created before this release still resolve. An unresolvable charge still yields `''` and the same logged 200 as before, never a retry storm. 2. **Every paid order granted twice.** The handler's `payload.update({ status: 'completed' })` fires the Orders collection's afterChange hook, which calls `onOrderCompleteDispatch`; the handler then invoked the consumer's `onOrderComplete` extra hook — and both shipped consumers wire that hook to the same dispatcher, so every order-complete handler ran twice and each purchase produced two entitlement rows. The handler now recognizes `onOrderComplete === onOrderCompleteDispatch`, skips it, and logs a warning naming the consumer fix (drop the line). `StripeWebhookHandlerConfig.onOrderComplete` is documented as site-level side effects only. Consumer follow-up: remove `onOrderComplete: onOrderCompleteDispatch` from the webhook route (the warning says so at runtime); no behavior change beyond the two fixes. Tests: four refund-resolution cases on the adapter, a single-dispatch case on the handler, and a `payment_intent_data` stamp assertion on session creation.

  • e5c4acb: **Two webhook defects found by the first live purchase-and-refund on a consumer site (2026-09-13). Both affect every consumer of `createStripeWebhookHandler`.** 1. **Refunds never resolved their Order.** `StripeAdapter.handleWebhook` mapped `charge.refunded` to `orderId = charge.metadata.orderId`, but one-time Checkout stamps metadata on the Session only and Stripe never copies it to the PaymentIntent or Charge. Every refund therefore arrived with `orderId: ''`, the handler skipped the Payment and Order writes, returned 200, and the consumer's refund-revocation hooks (keyed on `status: 'refunded'`) never fired — a refunded buyer kept the entitlement. Fix, in the adapter: `createCheckoutSession` now stamps `payment_intent_data.metadata` (the payment-mode twin of the `subscription_data.metadata` mirror the subscription path always had), and the refund mapping resolves the order from the charge's own metadata, then the PaymentIntent's metadata, then the Checkout Session listed by `payment_intent` — so charges from sessions created before this release still resolve. An unresolvable charge still yields `''` and the same logged 200 as before, never a retry storm. 2. **Every paid order granted twice.** The handler's `payload.update({ status: 'completed' })` fires the Orders collection's afterChange hook, which calls `onOrderCompleteDispatch`; the handler then invoked the consumer's `onOrderComplete` extra hook — and both shipped consumers wire that hook to the same dispatcher, so every order-complete handler ran twice and each purchase produced two entitlement rows. The handler now recognizes `onOrderComplete === onOrderCompleteDispatch`, skips it, and logs a warning naming the consumer fix (drop the line). `StripeWebhookHandlerConfig.onOrderComplete` is documented as site-level side effects only. Consumer follow-up: remove `onOrderComplete: onOrderCompleteDispatch` from the webhook route (the warning says so at runtime); no behavior change beyond the two fixes. Tests: four refund-resolution cases on the adapter, a single-dispatch case on the handler, and a `payment_intent_data` stamp assertion on session creation.
v0.10.0minor

48048dd: **Subscriptions collection + subscription checkout, and an open payment-provider seam — the two prerequisite PRs the `@wabbit/tome-directory` layer spec (§14 a/b) needs to build on.** 1. **New `Subscriptions` collection + `createSubscriptionCheckoutAction`.** `createEconomyLayer` now includes a Subscriptions collection by default (`subscriptions: false` to opt out, or a `SubscriptionsCollectionConfig` to override slugs/relationship targets) with fields `provider`, `providerRef`, `customerRef`, `account`/`member` (both optional relationships), `price`, `status` (`active|trialing|past_due|canceled|expired`), `currentPeriodStart`/`currentPeriodEnd`, `cancelAtPeriodEnd`, `metadata`. `createSubscriptionCheckoutAction` is the recurring-billing counterpart of `createCheckoutSessionAction` — same required `auth` identity gate, same `CheckoutIdentityMismatchError` — routes a Price with `interval: 'month' | 'year'` through `adapter.createSubscriptionSession` (throwing the new `SubscriptionCheckoutUnsupportedError` for a one-time Price or an adapter without recurring-billing support), creates the Subscriptions row eagerly (mirroring the pending-Order-before-redirect pattern), and calls an optional `persistCustomerRef` callback so a consumer can save the provider customer id onto its own record. `createSubscriptionPortalAction` and `cancelSubscriptionAction` round out the surface, both feature-detecting the adapter's optional `createPortalSession`/`cancelSubscription`. 2. **`createStripeWebhookHandler`'s `subscription.renewed` branch now writes a renewal Payment row and upserts the Subscriptions row.** Previously that branch dispatched the entitlement events but created no ledger row at all, unlike `checkout.completed` — a monthly Price yielded a working entitlement grant but an invisible billing history. The upsert resolves the row by `providerRef` (a direct hit on every renewal after the first) and falls back to a `metadata.userId`/`metadata.productId` match (the first renewal, before `providerRef` is finalized from the checkout-session id to the real provider subscription id); a subscription provisioned outside this package's checkout action gets a best-effort row rather than a silently dropped event. `subscription.cancelled`/`subscription.payment_failed` now also update the row's `status`. Because a renewal has no Order, `Payments.order` is now optional and a new `Payments.subscription` relationship carries the link instead — existing one-time-checkout Payment rows are unaffected (that write path still always sets `order`). 3. **Provider seam opened up (Stripe's own policy disqualifies it for the launching vertical, so a second real adapter, `@wabbit/tome-economy-authnet`, was always coming).** `PaymentProvider` widens from the closed `'stripe' | 'free' | 'manual'` to `'stripe' | 'free' | 'manual' | 'authorizenet' | (string & {})`; the Orders/Payments/Subscriptions `provider` fields convert from a closed Payload `select` to `text` + an open `validate` (any non-empty string; known values are documented for the admin UI only — see `collections/commerce/providerField.ts`). `createStripeWebhookHandler` reads the signature header via `adapter.webhookSignatureHeader ?? 'stripe-signature'` (new optional `PaymentProviderAdapter` member) instead of hardcoding Stripe's header name, and every Payment write now stamps `adapter.provider` instead of a hardcoded `'stripe'`. `PaymentProviderAdapter` also gains optional `cancelSubscription(args)` and `refund(args)`, both implemented on `StripeAdapter` (the pre-existing `StripeAdapter.cancelSubscription(subscriptionId, atPeriodEnd)` positional signature is now the interface's `{ providerRef, atPeriodEnd? }` object shape — a breaking change to that one method's own signature, safe because nothing in this repo called it yet). 4. **`@wabbit/tome-core` is now a REQUIRED peer.** The "genuinely optional" posture this package documented through 0.9.0 no longer held even before this change — `createCheckoutSessionAction.ts` already carried a static, module-scope `import { resolveMemberFromSession } from '@wabbit/tome-core/identity'`, so any consumer using that action already required core at runtime regardless of what `peerDependenciesMeta` claimed. `initEconomy` now imports `registerLayer` statically (its `try/catch` exists only for the "already registered" HMR/repeat-call case, matching every sibling `initXLayer`), and `createEconomyLayer` now actually applies the shared `access`/`hooks`/`extraFields`/`fieldOverrides`/`omitFields`/`fieldOrder` vocabulary from `@wabbit/tome-core/utilities/layerFactoryConfig` to every collection it returns — previously accepted on `EconomyLayerConfig` but explicitly documented as inert pending this exact trigger. `access/adminGate.ts`'s own promotion trigger ("the day core becomes a required peer") has therefore fired, but delegating that gate's implementation to core's `sessionHasCapabilityOrLegacyAdmin` primitive is a separate, larger change this PR deliberately does not bundle — recorded in that file's header rather than half-done silently. Consumer follow-ups: a site wiring `@wabbit/tome-accounts`'s forthcoming `billing` group (prerequisite PR (c)) as `persistCustomerRef` gets provider-agnostic customer-ref persistence on first subscription checkout for free. `@wabbit/tome-economy-authnet` (prerequisite PR (b)'s stated reason for the provider seam) can now implement `PaymentProviderAdapter` in full, including `webhookSignatureHeader: 'X-ANET-Signature'` and `cancelSubscription`/`refund`.

  • 48048dd: **Subscriptions collection + subscription checkout, and an open payment-provider seam — the two prerequisite PRs the `@wabbit/tome-directory` layer spec (§14 a/b) needs to build on.** 1. **New `Subscriptions` collection + `createSubscriptionCheckoutAction`.** `createEconomyLayer` now includes a Subscriptions collection by default (`subscriptions: false` to opt out, or a `SubscriptionsCollectionConfig` to override slugs/relationship targets) with fields `provider`, `providerRef`, `customerRef`, `account`/`member` (both optional relationships), `price`, `status` (`active|trialing|past_due|canceled|expired`), `currentPeriodStart`/`currentPeriodEnd`, `cancelAtPeriodEnd`, `metadata`. `createSubscriptionCheckoutAction` is the recurring-billing counterpart of `createCheckoutSessionAction` — same required `auth` identity gate, same `CheckoutIdentityMismatchError` — routes a Price with `interval: 'month' | 'year'` through `adapter.createSubscriptionSession` (throwing the new `SubscriptionCheckoutUnsupportedError` for a one-time Price or an adapter without recurring-billing support), creates the Subscriptions row eagerly (mirroring the pending-Order-before-redirect pattern), and calls an optional `persistCustomerRef` callback so a consumer can save the provider customer id onto its own record. `createSubscriptionPortalAction` and `cancelSubscriptionAction` round out the surface, both feature-detecting the adapter's optional `createPortalSession`/`cancelSubscription`. 2. **`createStripeWebhookHandler`'s `subscription.renewed` branch now writes a renewal Payment row and upserts the Subscriptions row.** Previously that branch dispatched the entitlement events but created no ledger row at all, unlike `checkout.completed` — a monthly Price yielded a working entitlement grant but an invisible billing history. The upsert resolves the row by `providerRef` (a direct hit on every renewal after the first) and falls back to a `metadata.userId`/`metadata.productId` match (the first renewal, before `providerRef` is finalized from the checkout-session id to the real provider subscription id); a subscription provisioned outside this package's checkout action gets a best-effort row rather than a silently dropped event. `subscription.cancelled`/`subscription.payment_failed` now also update the row's `status`. Because a renewal has no Order, `Payments.order` is now optional and a new `Payments.subscription` relationship carries the link instead — existing one-time-checkout Payment rows are unaffected (that write path still always sets `order`). 3. **Provider seam opened up (Stripe's own policy disqualifies it for the launching vertical, so a second real adapter, `@wabbit/tome-economy-authnet`, was always coming).** `PaymentProvider` widens from the closed `'stripe' | 'free' | 'manual'` to `'stripe' | 'free' | 'manual' | 'authorizenet' | (string & {})`; the Orders/Payments/Subscriptions `provider` fields convert from a closed Payload `select` to `text` + an open `validate` (any non-empty string; known values are documented for the admin UI only — see `collections/commerce/providerField.ts`). `createStripeWebhookHandler` reads the signature header via `adapter.webhookSignatureHeader ?? 'stripe-signature'` (new optional `PaymentProviderAdapter` member) instead of hardcoding Stripe's header name, and every Payment write now stamps `adapter.provider` instead of a hardcoded `'stripe'`. `PaymentProviderAdapter` also gains optional `cancelSubscription(args)` and `refund(args)`, both implemented on `StripeAdapter` (the pre-existing `StripeAdapter.cancelSubscription(subscriptionId, atPeriodEnd)` positional signature is now the interface's `{ providerRef, atPeriodEnd? }` object shape — a breaking change to that one method's own signature, safe because nothing in this repo called it yet). 4. **`@wabbit/tome-core` is now a REQUIRED peer.** The "genuinely optional" posture this package documented through 0.9.0 no longer held even before this change — `createCheckoutSessionAction.ts` already carried a static, module-scope `import { resolveMemberFromSession } from '@wabbit/tome-core/identity'`, so any consumer using that action already required core at runtime regardless of what `peerDependenciesMeta` claimed. `initEconomy` now imports `registerLayer` statically (its `try/catch` exists only for the "already registered" HMR/repeat-call case, matching every sibling `initXLayer`), and `createEconomyLayer` now actually applies the shared `access`/`hooks`/`extraFields`/`fieldOverrides`/`omitFields`/`fieldOrder` vocabulary from `@wabbit/tome-core/utilities/layerFactoryConfig` to every collection it returns — previously accepted on `EconomyLayerConfig` but explicitly documented as inert pending this exact trigger. `access/adminGate.ts`'s own promotion trigger ("the day core becomes a required peer") has therefore fired, but delegating that gate's implementation to core's `sessionHasCapabilityOrLegacyAdmin` primitive is a separate, larger change this PR deliberately does not bundle — recorded in that file's header rather than half-done silently. Consumer follow-ups: a site wiring `@wabbit/tome-accounts`'s forthcoming `billing` group (prerequisite PR (c)) as `persistCustomerRef` gets provider-agnostic customer-ref persistence on first subscription checkout for free. `@wabbit/tome-economy-authnet` (prerequisite PR (b)'s stated reason for the provider seam) can now implement `PaymentProviderAdapter` in full, including `webhookSignatureHeader: 'X-ANET-Signature'` and `cancelSubscription`/`refund`.
v0.9.0minor

7d0949f4: **Three additive checkout/webhook seams, surfaced by the first non-LMS consumer (2026-09-04).** 1. **`items[].productType` is now stamped at checkout.** Orders has declared the field since 0.3.0 and `onOrderCompleteDispatch` reads it, but `createCheckoutSessionAction` never wrote it, so every non-course product was dispatched to order-complete handlers as `'course'` and consumers had to re-stamp the line item themselves. The action now denormalises the catalog product's `type` onto the line item and into the provider session metadata (`metadata.productType`). Products with no `type` are unchanged (the key is omitted, never written empty). 2. **Promotion codes and site-applied discounts pass through to Stripe Checkout.** `CheckoutSessionInput` and `CreateCheckoutSessionArgs` gain optional `allowPromotionCodes?: boolean` (Stripe `allow_promotion_codes`) and `discounts?: Array<{ coupon?: string; promotionCode?: string }>` (Stripe `discounts`). Both are absent from the adapter call and the Stripe request on the default path, so existing sessions are byte-identical. Stripe forbids the two keys together: when both are given, `discounts` is applied and the code box is suppressed for that session. `FreeAdapter` ignores both. 3. **`createStripeWebhookHandler` gains `onSetupCompleted`.** The adapter has emitted `setup.completed` (a `mode: 'setup'` Checkout Session: card saved, nothing charged) since 0.6.0, but the handler dropped it, and wabbit-site-core worked around that by classifying a cloned request ahead of the handler. The handler now calls `onSetupCompleted(event)` when configured. It writes nothing itself (there is no Order or Payment for a setup session; the consumer owns the pledge row), a throwing hook is logged and the route still answers 200, and with no hook registered the event is acknowledged and ignored exactly as before. The event type is exported as `SetupCompletedWebhookEvent`. Consumer follow-ups: wabbit-site-core can retire the request-clone peek in `src/app/api/webhooks/stripe/route.ts` by passing `onSetupCompleted: (e) => handleSetupCompleted(payload, e)`; the other consumer can drop its consumer-side `items[].productType` re-stamp and pass `allowPromotionCodes: true` for the founder/early-bird codes.

  • 7d0949f4: **Three additive checkout/webhook seams, surfaced by the first non-LMS consumer (2026-09-04).** 1. **`items[].productType` is now stamped at checkout.** Orders has declared the field since 0.3.0 and `onOrderCompleteDispatch` reads it, but `createCheckoutSessionAction` never wrote it, so every non-course product was dispatched to order-complete handlers as `'course'` and consumers had to re-stamp the line item themselves. The action now denormalises the catalog product's `type` onto the line item and into the provider session metadata (`metadata.productType`). Products with no `type` are unchanged (the key is omitted, never written empty). 2. **Promotion codes and site-applied discounts pass through to Stripe Checkout.** `CheckoutSessionInput` and `CreateCheckoutSessionArgs` gain optional `allowPromotionCodes?: boolean` (Stripe `allow_promotion_codes`) and `discounts?: Array<{ coupon?: string; promotionCode?: string }>` (Stripe `discounts`). Both are absent from the adapter call and the Stripe request on the default path, so existing sessions are byte-identical. Stripe forbids the two keys together: when both are given, `discounts` is applied and the code box is suppressed for that session. `FreeAdapter` ignores both. 3. **`createStripeWebhookHandler` gains `onSetupCompleted`.** The adapter has emitted `setup.completed` (a `mode: 'setup'` Checkout Session: card saved, nothing charged) since 0.6.0, but the handler dropped it, and wabbit-site-core worked around that by classifying a cloned request ahead of the handler. The handler now calls `onSetupCompleted(event)` when configured. It writes nothing itself (there is no Order or Payment for a setup session; the consumer owns the pledge row), a throwing hook is logged and the route still answers 200, and with no hook registered the event is acknowledged and ignored exactly as before. The event type is exported as `SetupCompletedWebhookEvent`. Consumer follow-ups: wabbit-site-core can retire the request-clone peek in `src/app/api/webhooks/stripe/route.ts` by passing `onSetupCompleted: (e) => handleSetupCompleted(payload, e)`; the other consumer can drop its consumer-side `items[].productType` re-stamp and pass `allowPromotionCodes: true` for the founder/early-bird codes.
v0.8.0minor

670d2a1: **Breaking (0.x) — `createCheckoutSessionAction`'s returned action now takes a required second argument.** The action creates Orders with `overrideAccess: true` and previously trusted the `memberId`/`memberEmail` it was handed. The auth contract lived only in a JSDoc usage example, so a consumer that forgot to resolve the session — or resolved it and then passed a client-supplied id — shipped an IDOR: any signed-in customer could mint a pending Order against another member, with that member's id carried into the payment provider's metadata. A comment cannot fail a build, so the check is now at runtime. The returned function is `action(input, auth)`. `auth` is `{ user }` — pass a Payload `req.user`-like object verbatim. Before the Order is written or the adapter called, the action resolves the session user's **member row** (via `@wabbit/tome-core/identity`'s `resolveMemberFromSession`, one `find` on the members collection) and asserts that row is `input.memberId`. A user id is never compared to a member id: they are different collections, and every shipping consumer — tome-starter, wabbit-site-core — passes a members-row id. `input.memberEmail` must match the session's email or the resolved member's, case-insensitively. A failure throws the new exported `CheckoutIdentityMismatchError` (`.reason` is `'missing-auth' | 'no-member' | 'id-mismatch' | 'email-mismatch'`, `.code` is `'checkout-identity-mismatch'`); map it to a 403. Writes keep `overrideAccess: true` — that bypass is now safe precisely because the caller's identity is proven rather than assumed. Consumer edit — in your `'use server'` wrapper, resolve the session server-side and stop taking the member from the client: ```diff - export async function enrollAction(productId: string, memberId: string, memberEmail: string) { + export async function enrollAction(productId: string) { const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: await headers() }) + if (!user) throw new Error('Not signed in') + // orders.customer -> members. Resolve the buyer's member row from the + // session; the action re-derives it independently and must agree. + const member = (await payload.find({ + collection: 'members', where: { user: { equals: user.id } }, limit: 1, depth: 0, overrideAccess: true, + })).docs[0] + if (!member) throw new Error('No member profile for this account') const action = createCheckoutSessionAction({ adapter, payload, baseUrl }) - return action({ productId, memberId, memberEmail }) + return action( + { productId, memberId: String(member.id), memberEmail: user.email }, + { user }, + ) } ``` A genuinely anonymous flow (guest checkout, or a server-to-server job that authorized the purchase upstream) opts out with `{ allowUnauthenticatedCaller: true }`, which is documented as dangerous and does NOT relax the id/email assertions when a `user` is present. New exports: `CheckoutIdentityMismatchError`, `CheckoutCallerIdentity`, `CheckoutIdentityFailureReason`. Also: the `TODO(post-v0): add pending-order expiry mechanism (Risk R4)` is now a stated accepted risk with explicit build triggers (pending Orders accumulating in production, a second write path creating pending Orders, or checkout exposed to unauthenticated callers) instead of an open-ended TODO. No implementation change. Tests: 15 assertions covering a user whose member row is the memberId (ids differ across collections), a user with no member row, id mismatch (throws before any read, write or adapter call), missing `auth`, absent session, the explicit unauthenticated opt-in, and the opt-in NOT overriding a present-but-wrong user.

  • 670d2a1: **Breaking (0.x) — `createCheckoutSessionAction`'s returned action now takes a required second argument.** The action creates Orders with `overrideAccess: true` and previously trusted the `memberId`/`memberEmail` it was handed. The auth contract lived only in a JSDoc usage example, so a consumer that forgot to resolve the session — or resolved it and then passed a client-supplied id — shipped an IDOR: any signed-in customer could mint a pending Order against another member, with that member's id carried into the payment provider's metadata. A comment cannot fail a build, so the check is now at runtime. The returned function is `action(input, auth)`. `auth` is `{ user }` — pass a Payload `req.user`-like object verbatim. Before the Order is written or the adapter called, the action resolves the session user's **member row** (via `@wabbit/tome-core/identity`'s `resolveMemberFromSession`, one `find` on the members collection) and asserts that row is `input.memberId`. A user id is never compared to a member id: they are different collections, and every shipping consumer — tome-starter, wabbit-site-core — passes a members-row id. `input.memberEmail` must match the session's email or the resolved member's, case-insensitively. A failure throws the new exported `CheckoutIdentityMismatchError` (`.reason` is `'missing-auth' | 'no-member' | 'id-mismatch' | 'email-mismatch'`, `.code` is `'checkout-identity-mismatch'`); map it to a 403. Writes keep `overrideAccess: true` — that bypass is now safe precisely because the caller's identity is proven rather than assumed. Consumer edit — in your `'use server'` wrapper, resolve the session server-side and stop taking the member from the client: ```diff - export async function enrollAction(productId: string, memberId: string, memberEmail: string) { + export async function enrollAction(productId: string) { const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: await headers() }) + if (!user) throw new Error('Not signed in') + // orders.customer -> members. Resolve the buyer's member row from the + // session; the action re-derives it independently and must agree. + const member = (await payload.find({ + collection: 'members', where: { user: { equals: user.id } }, limit: 1, depth: 0, overrideAccess: true, + })).docs[0] + if (!member) throw new Error('No member profile for this account') const action = createCheckoutSessionAction({ adapter, payload, baseUrl }) - return action({ productId, memberId, memberEmail }) + return action( + { productId, memberId: String(member.id), memberEmail: user.email }, + { user }, + ) } ``` A genuinely anonymous flow (guest checkout, or a server-to-server job that authorized the purchase upstream) opts out with `{ allowUnauthenticatedCaller: true }`, which is documented as dangerous and does NOT relax the id/email assertions when a `user` is present. New exports: `CheckoutIdentityMismatchError`, `CheckoutCallerIdentity`, `CheckoutIdentityFailureReason`. Also: the `TODO(post-v0): add pending-order expiry mechanism (Risk R4)` is now a stated accepted risk with explicit build triggers (pending Orders accumulating in production, a second write path creating pending Orders, or checkout exposed to unauthenticated callers) instead of an open-ended TODO. No implementation change. Tests: 15 assertions covering a user whose member row is the memberId (ids differ across collections), a user with no member row, id mismatch (throws before any read, write or adapter call), missing `auth`, absent session, the explicit unauthenticated opt-in, and the opt-in NOT overriding a present-but-wrong user.
  • 0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behavior change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
v0.7.0minor

Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.

  • Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.
v0.6.0minor

Optional adapter methods for crowdfund settlement (B2 precedent — additive interface members): createSetupSession (hosted Checkout mode:'setup'), chargeSavedPaymentMethod (off-session PaymentIntent with Stripe idempotency-key request option; declined/SCA failures returned as typed results), releaseSavedPaymentMethod. WebhookEvent union gains setup.completed, discriminated on session mode — mode:'payment'/'subscription' mappings pinned field-for-field unchanged.

  • Optional adapter methods for crowdfund settlement (B2 precedent — additive interface members): createSetupSession (hosted Checkout mode:'setup'), chargeSavedPaymentMethod (off-session PaymentIntent with Stripe idempotency-key request option; declined/SCA failures returned as typed results), releaseSavedPaymentMethod. WebhookEvent union gains setup.completed, discriminated on session mode — mode:'payment'/'subscription' mappings pinned field-for-field unchanged.
  • 1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).
v0.5.0minor

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: 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.
v0.4.2patch

Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

  • Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
v0.4.1patch

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.
v0.4.0minor

Add an opt-in `subscription.payment_failed` dispatch seam for dunning. New exports: `registerSubscriptionPaymentFailedHandler`, `onSubscriptionPaymentFailedDispatch`, and the `SubscriptionPaymentFailedEvent` / `SubscriptionPaymentFailedHandler` types. The Stripe webhook handler now dispatches failed renewal charges to registered consumer handlers (in addition to the existing observability log) instead of dropping them, so a consumer can flag the billing record past_due, alert the team, and email the customer. No-ops when no handler is registered, so existing consumers are unaffected.

  • Add an opt-in `subscription.payment_failed` dispatch seam for dunning. New exports: `registerSubscriptionPaymentFailedHandler`, `onSubscriptionPaymentFailedDispatch`, and the `SubscriptionPaymentFailedEvent` / `SubscriptionPaymentFailedHandler` types. The Stripe webhook handler now dispatches failed renewal charges to registered consumer handlers (in addition to the existing observability log) instead of dropping them, so a consumer can flag the billing record past_due, alert the team, and email the customer. No-ops when no handler is registered, so existing consumers are unaffected.
v0.3.1patch

StripeAdapter: resolve the subscription id on `invoice.paid` / `invoice.payment_failed` across Stripe API shapes. The handler previously read only the top-level `invoice.subscription` field, which Stripe removed in API `2025-03-31.basil` (the SDK is pinned to `2026-04-22.dahlia`). On a Basil+ webhook payload the id resolved to `''`, the metadata fallback never ran, and every subscription lifecycle event silently skipped (no entitlement grant, no billing record). Now reads `invoice.parent.subscription_details.{subscription,metadata}` and the per-line `parent.subscription_item_details.subscription`, falling back to the legacy field — correct regardless of the webhook endpoint's pinned API version.

  • StripeAdapter: resolve the subscription id on `invoice.paid` / `invoice.payment_failed` across Stripe API shapes. The handler previously read only the top-level `invoice.subscription` field, which Stripe removed in API `2025-03-31.basil` (the SDK is pinned to `2026-04-22.dahlia`). On a Basil+ webhook payload the id resolved to `''`, the metadata fallback never ran, and every subscription lifecycle event silently skipped (no entitlement grant, no billing record). Now reads `invoice.parent.subscription_details.{subscription,metadata}` and the per-line `parent.subscription_item_details.subscription`, falling back to the legacy field — correct regardless of the webhook endpoint's pinned API version.
v0.3.0minor

61af0ea: Add subscription / recurring-billing support to the Stripe provider and webhook handler. New optional adapter methods `createSubscriptionSession` and `createPortalSession`, plus subscription/customer helpers; the webhook handler now maps the subscription lifecycle (`invoice.paid` -> renewed, `customer.subscription.updated(cancel_at_period_end)` + `.deleted` -> canceled, `invoice.payment_failed` -> payment_failed) and dispatches via a new `onSubscriptionComplete` event bus. Adds `Prices.interval` (one-time / month / year) and `Orders.items[].productType`. Fully additive — one-time Checkout and the existing order/webhook path are unchanged.

  • 61af0ea: Add subscription / recurring-billing support to the Stripe provider and webhook handler. New optional adapter methods `createSubscriptionSession` and `createPortalSession`, plus subscription/customer helpers; the webhook handler now maps the subscription lifecycle (`invoice.paid` -> renewed, `customer.subscription.updated(cancel_at_period_end)` + `.deleted` -> canceled, `invoice.payment_failed` -> payment_failed) and dispatches via a new `onSubscriptionComplete` event bus. Adds `Prices.interval` (one-time / month / year) and `Orders.items[].productType`. Fully additive — one-time Checkout and the existing order/webhook path are unchanged.
v0.2.7patch

935ce94: Fix: the order-complete handler registry is now a `globalThis`-keyed singleton so handler registration (typically in Payload `onInit`) and `onOrderCompleteDispatch` always share one list. Previously the registry was a module-local array; bundlers (notably Next.js) can load the module in more than one server bundle — e.g. the `onInit`/server-action context vs. an API route-handler bundle — so a webhook route would dispatch against an empty registry and silently drop every order completion (no enrollment or proposal-payment handler ran, even though the order/payment rows were written). `onOrderCompleteDispatch` now also logs a warning instead of returning silently when it dispatches with zero handlers, so this class of misconfiguration can never fail silently again.

  • 935ce94: Fix: the order-complete handler registry is now a `globalThis`-keyed singleton so handler registration (typically in Payload `onInit`) and `onOrderCompleteDispatch` always share one list. Previously the registry was a module-local array; bundlers (notably Next.js) can load the module in more than one server bundle — e.g. the `onInit`/server-action context vs. an API route-handler bundle — so a webhook route would dispatch against an empty registry and silently drop every order completion (no enrollment or proposal-payment handler ran, even though the order/payment rows were written). `onOrderCompleteDispatch` now also logs a warning instead of returning silently when it dispatches with zero handlers, so this class of misconfiguration can never fail silently again.
v0.2.6patch

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.
v0.2.5patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.2.4patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v1.0.0patch

Updated dependencies - @wabbit/tome-core@0.2.0

  • Updated dependencies - @wabbit/tome-core@0.2.0

Catalog

v1.10.0
v1.10.0minor

e2f64c4: **Update your code first:** the commerce writers now store the American values. New rows hold `canceled` and `canceling`, so a site that compares to `'cancelled'` or `'cancelling'` must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values. Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store `canceled`. Entitlements canceling at period end store `canceling`, and the order overview's access status reports it. Restock requests store `canceled`, and a canceled shipment prefers `canceled` when the shipments collection offers it. Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step. On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.

  • e2f64c4: **Update your code first:** the commerce writers now store the American values. New rows hold `canceled` and `canceling`, so a site that compares to `'cancelled'` or `'cancelling'` must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values. Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store `canceled`. Entitlements canceling at period end store `canceling`, and the order overview's access status reports it. Restock requests store `canceled`, and a canceled shipment prefers `canceled` when the shipments collection offers it. Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step. On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.
v1.9.0minor

0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.

  • 0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.
v1.8.3patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v1.8.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v1.8.1patch

d45c399: Admins whose admin rights come from RBAC role records (`roles: [<admin role>]`) can read every Entitlement again; before, only a legacy flat `role` passed the default read rule. - `entitlementsOwnerOrAdminRead` keeps the legacy check and adds core's role-aware `isAdmin`, which resolves populated roles or fetches them by id. - Effect: on RBAC sites the Order Overview's access tiles and the Entitlements admin list were empty for admins. Sites that pass their own `access.read` are unaffected.

  • d45c399: Admins whose admin rights come from RBAC role records (`roles: [<admin role>]`) can read every Entitlement again; before, only a legacy flat `role` passed the default read rule. - `entitlementsOwnerOrAdminRead` keeps the legacy check and adds core's role-aware `isAdmin`, which resolves populated roles or fetches them by id. - Effect: on RBAC sites the Order Overview's access tiles and the Entitlements admin list were empty for admins. Sites that pass their own `access.read` are unaffected.
v1.8.0minor

56f143c: **BREAKING:** Entitlements now record who revoked access, when and why, through new fields that need a database migration on SQL adapters. **Migration:** On SQL adapters generate and run a migration for the new nullable entitlement fields; MongoDB needs none. Otherwise the change is additive. - New fields: `revokedAt`, `revokedReason` (refund / admin / chargeback / policy), `revokedBy`, `revokedByOrder`, `revokedByRefundId` and `revokedNote`. - A built-in `stampRevocationAudit` beforeChange hook (exported) stamps `revokedAt`, `revokedBy` and a default reason on the transition into `revoked`, ahead of consumer hooks. - The `status` description now reads that `revoked` means access withdrawn by an admin or the refund policy. - The new field names are the frozen revocation contract that economy writes by slug.

  • 56f143c: **BREAKING:** Entitlements now record who revoked access, when and why, through new fields that need a database migration on SQL adapters. **Migration:** On SQL adapters generate and run a migration for the new nullable entitlement fields; MongoDB needs none. Otherwise the change is additive. - New fields: `revokedAt`, `revokedReason` (refund / admin / chargeback / policy), `revokedBy`, `revokedByOrder`, `revokedByRefundId` and `revokedNote`. - A built-in `stampRevocationAudit` beforeChange hook (exported) stamps `revokedAt`, `revokedBy` and a default reason on the transition into `revoked`, ahead of consumer hooks. - The `status` description now reads that `revoked` means access withdrawn by an admin or the refund policy. - The new field names are the frozen revocation contract that economy writes by slug.
v1.7.0minor

7a9eb6d: Products gain an optional `sortOrder` number (default 0, in the admin sidebar), the manual order a storefront's "Featured" sort follows: lower numbers first, ties by title. It matches the `sortOrder` that categories, variants, media and attributes already carry. Nothing reads it unless a storefront listing asks for the `featured` sort. A store on SQL adds the column in its next migration; a MongoDB store needs none. Products created before the field existed have no value until it is set.

  • 7a9eb6d: Products gain an optional `sortOrder` number (default 0, in the admin sidebar), the manual order a storefront's "Featured" sort follows: lower numbers first, ties by title. It matches the `sortOrder` that categories, variants, media and attributes already carry. Nothing reads it unless a storefront listing asks for the `featured` sort. A store on SQL adds the column in its next migration; a MongoDB store needs none. Products created before the field existed have no value until it is set.
v1.6.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v1.6.0minor

c071259: **BREAKING:** a default changed — see the Migration note below. Entitlements are now readable only by their owner or an admin by default, and four catalog defaults now behave as documented. - Security fix: without `vendorScoped`, the Entitlements collection's default `read` let any signed-in user read every entitlement. It is now owner-or-admin: admins read all rows, other users read rows whose `userId` is theirs, anonymous requests read nothing. **Migration:** a site that relied on the old open read (for example, account members reading an account's grants) passes its own `access.read`. - The default `physical`/`digital`/`service`/`subscription` product card hooks now find the products collection under a renamed slug instead of always querying `catalog-products`. `createProductCollection` marks its collection in `custom.tomeCatalogProducts` for this lookup. - `upsertEntitlementForSubscription` no longer leaves duplicate rows when two deliveries of the same webhook run at once; duplicates fold back to the oldest row, and a duplicate-key error from a unique index on the key updates the existing row instead of failing. - `createCategoryCollection({ maxDepth })` now rejects a write that would nest a category deeper than `maxDepth`; it was previously shown in the admin description only.

  • c071259: **BREAKING:** a default changed — see the Migration note below. Entitlements are now readable only by their owner or an admin by default, and four catalog defaults now behave as documented. - Security fix: without `vendorScoped`, the Entitlements collection's default `read` let any signed-in user read every entitlement. It is now owner-or-admin: admins read all rows, other users read rows whose `userId` is theirs, anonymous requests read nothing. **Migration:** a site that relied on the old open read (for example, account members reading an account's grants) passes its own `access.read`. - The default `physical`/`digital`/`service`/`subscription` product card hooks now find the products collection under a renamed slug instead of always querying `catalog-products`. `createProductCollection` marks its collection in `custom.tomeCatalogProducts` for this lookup. - `upsertEntitlementForSubscription` no longer leaves duplicate rows when two deliveries of the same webhook run at once; duplicates fold back to the oldest row, and a duplicate-key error from a unique index on the key updates the existing row instead of failing. - `createCategoryCollection({ maxDepth })` now rejects a write that would nest a category deeper than `maxDepth`; it was previously shown in the admin description only.
  • 7b8f2b6: The variant option-uniqueness check now reads a product's sibling variants in bounded pages instead of one unbounded read. Validation is unchanged.
  • 96872f7: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Package` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v1.5.2patch

67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`.

  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`.
v1.5.1patch

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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.

  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
v1.5.0minor

Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.

  • Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.
v1.4.1patch

1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).

  • 1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).
v1.4.0minor

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: `createProductCollection({ vendorScoped: true })` now actually enforces vendor ownership, closing the gap where the flag rendered the `vendor` field but left `create`/`update`/`delete` fully open. When the flag is on AND `@wabbit/tome-org` is registered: create requires an authenticated user, update/delete require vendor ownership (admin bypass via core's `checkRole`), and a `beforeChange` hook force-assigns `vendor` on create. Explicit `config.access` still wins per-operation. Flag absent/false = byte-identical behavior to before (pinned by new tests — catalog gains a vitest suite, 24 tests).
  • 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.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v1.3.1patch

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.
v1.3.0minor

b4e5625: catalog: account-scope entitlements (v3 Amendment A2). Adds a nullable `accountId` relationship to the Entitlements collection (configurable via `accountsSlug`, default `'accounts'`) so an entitlement can be owned by a multi-tenant account (`@wabbit/tome-accounts`). `getEntitlements` now accepts `accountId` as an alternative owner filter (provide `userId` OR `accountId`; exactly one required) and `upsertEntitlementForSubscription` dual-writes `accountId` when provided. Fully additive: `userId` stays the required, contract-frozen owner through the transition (grant handlers dual-write `accountId`, then a backfill populates existing rows), and the original v3 frozen fields + the A1 subscription fields are unchanged. Orthogonal to the still-pending `userId → memberId` member-identity refactor.

  • b4e5625: catalog: account-scope entitlements (v3 Amendment A2). Adds a nullable `accountId` relationship to the Entitlements collection (configurable via `accountsSlug`, default `'accounts'`) so an entitlement can be owned by a multi-tenant account (`@wabbit/tome-accounts`). `getEntitlements` now accepts `accountId` as an alternative owner filter (provide `userId` OR `accountId`; exactly one required) and `upsertEntitlementForSubscription` dual-writes `accountId` when provided. Fully additive: `userId` stays the required, contract-frozen owner through the transition (grant handlers dual-write `accountId`, then a backfill populates existing rows), and the original v3 frozen fields + the A1 subscription fields are unchanged. Orthogonal to the still-pending `userId → memberId` member-identity refactor.
v1.2.0minor

61af0ea: Entitlements gain subscription-bounded access fields (`expiresAt`, `subscriptionId`, `status` including a `cancelling` state) — the entitlement row is the access gate, billing state lives consumer-local. `getEntitlements` now filters to `active`/`cancelling` + non-expired rows (rows with no `status` are treated as active for backward compatibility). New idempotent `upsertEntitlementForSubscription` query keyed on (userId, productId). Additive — existing lifetime grants are unaffected (`expiresAt` null = lifetime).

  • 61af0ea: Entitlements gain subscription-bounded access fields (`expiresAt`, `subscriptionId`, `status` including a `cancelling` state) — the entitlement row is the access gate, billing state lives consumer-local. `getEntitlements` now filters to `active`/`cancelling` + non-expired rows (rows with no `status` are treated as active for backward compatibility). New idempotent `upsertEntitlementForSubscription` query keyed on (userId, productId). Additive — existing lifetime grants are unaffected (`expiresAt` null = lifetime).
v1.1.4patch

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • 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.
v1.1.3patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v1.1.2patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v1.0.0patch

Updated dependencies - @wabbit/tome-core@0.2.0

  • Updated dependencies - @wabbit/tome-core@0.2.0

Fulfillment

v0.3.3
v0.3.3patch

0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.

  • 0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.
v0.3.2patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.3.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.0minor

45d8fbd: Adds an opt-in `commitStockOnShip` hook and a `cancelled` shipment status. - `createFulfillmentLayer({ commitStockOnShip: true | { staffRecipients } })` (also on `createShipmentsCollection`, with `stockSlug`). When a shipment enters `shipped` (or `delivered` without passing through `shipped`), its own lines' reserved stock is committed: `reserved` and `onHand` fall under `commit:<orderId>:<shipmentId>:<sku>`. Partial shipments commit only their lines; a replay writes nothing. A failure is logged and raised as a staff task through core's notifier, and never blocks the status change. Off by default: sites that do not opt in behave exactly as before. - `commitShipmentStock(payload, shipment)` and `commitReason()` on `@wabbit/tome-fulfillment/server`, to retry a shipment whose commit failed. `createCommitStockOnShipHook` and its option types on the root entry. - Shipments `status` gains `cancelled` (closed before shipping; distinct from `returned`). **Migration for SQL consumers:** the status enum changes, so run `payload migrate:create` and apply it before any row is written with `cancelled`. Mongo needs nothing. Storefront's refund handler switches from a "do not ship" note to `cancelled` once it sees the value.

  • 45d8fbd: Adds an opt-in `commitStockOnShip` hook and a `cancelled` shipment status. - `createFulfillmentLayer({ commitStockOnShip: true | { staffRecipients } })` (also on `createShipmentsCollection`, with `stockSlug`). When a shipment enters `shipped` (or `delivered` without passing through `shipped`), its own lines' reserved stock is committed: `reserved` and `onHand` fall under `commit:<orderId>:<shipmentId>:<sku>`. Partial shipments commit only their lines; a replay writes nothing. A failure is logged and raised as a staff task through core's notifier, and never blocks the status change. Off by default: sites that do not opt in behave exactly as before. - `commitShipmentStock(payload, shipment)` and `commitReason()` on `@wabbit/tome-fulfillment/server`, to retry a shipment whose commit failed. `createCommitStockOnShipHook` and its option types on the root entry. - Shipments `status` gains `cancelled` (closed before shipping; distinct from `returned`). **Migration for SQL consumers:** the status enum changes, so run `payload migrate:create` and apply it before any row is written with `cancelled`. Mongo needs nothing. Storefront's refund handler switches from a "do not ship" note to `cancelled` once it sees the value.
v0.2.0minor

bf3f667: New read-only helper `getStockAvailability(payload, { skus, stockSlug? })` on `@wabbit/tome-fulfillment/server`. It looks up many SKUs in one query and returns `{ sku, tracked, available }` per distinct SKU, where `available` is `onHand - reserved` clamped at 0 and a SKU with no stock row comes back `tracked: false`. It runs server-side with `overrideAccess`, so stock read access stays admin-only; a public storefront should project these raw counts rather than expose them.

  • bf3f667: New read-only helper `getStockAvailability(payload, { skus, stockSlug? })` on `@wabbit/tome-fulfillment/server`. It looks up many SKUs in one query and returns `{ sku, tracked, available }` per distinct SKU, where `available` is `onHand - reserved` clamped at 0 and a SKU with no stock row comes back `tracked: false`. It runs server-side with `overrideAccess`, so stock read access stays admin-only; a public storefront should project these raw counts rather than expose them.
v0.1.4patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.1.3patch

0edd49b: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Truck` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.

  • 0edd49b: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Truck` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
v0.1.2patch

30bdd74: `ownOrAdminRead` / `ownOrAdminWrite` now delegate to core's `ownOrCapabilityAccess`, with identical results. This closes the crowdfund/fulfillment near-fork that `assert:no-forked-primitives` flagged.

  • 30bdd74: `ownOrAdminRead` / `ownOrAdminWrite` now delegate to core's `ownOrCapabilityAccess`, with identical results. This closes the crowdfund/fulfillment near-fork that `assert:no-forked-primitives` flagged.
  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `resolveRelationId` (public) is kept as a deprecated delegate to `relationIdRaw`, with identical semantics.
v0.1.1patch

0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behavior change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.

  • 0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behavior change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.
  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 637db74: Seed the CHANGELOG.md that the `files` field ships but which never existed (the package published 0.1.0 without a changeset).
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
v0.1.0minor

5dda6c04: NEW package — Tome fulfillment layer: shipping addresses (GDPR-registered), stock and stock adjustments, shipments, and pluggable rate/tax seams (`createFlatRateTable`, `noopTaxAdapter`). Server helpers on `./server`. Family `commerce`, tier `pro` (stamped in eb317800).

  • 5dda6c04: NEW package — Tome fulfillment layer: shipping addresses (GDPR-registered), stock and stock adjustments, shipments, and pluggable rate/tax seams (`createFlatRateTable`, `noopTaxAdapter`). Server helpers on `./server`. Family `commerce`, tier `pro` (stamped in eb317800).

Storefront

v0.6.0
v0.6.0minor

e2f64c4: **Update your code first:** the commerce writers now store the American values. New rows hold `canceled` and `canceling`, so a site that compares to `'cancelled'` or `'cancelling'` must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values. Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store `canceled`. Entitlements canceling at period end store `canceling`, and the order overview's access status reports it. Restock requests store `canceled`, and a canceled shipment prefers `canceled` when the shipments collection offers it. Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step. On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.

  • e2f64c4: **Update your code first:** the commerce writers now store the American values. New rows hold `canceled` and `canceling`, so a site that compares to `'cancelled'` or `'cancelling'` must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values. Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store `canceled`. Entitlements canceling at period end store `canceling`, and the order overview's access status reports it. Restock requests store `canceled`, and a canceled shipment prefers `canceled` when the shipments collection offers it. Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step. On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.
v0.5.1patch

0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.

  • 0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.
v0.5.0minor

65020e0: **BREAKING:** `handleStorefrontOrderCancelled` is now `handleStorefrontOrderCanceled`. The bus registration key ends in `order-canceled`, and the stale-order job summary fields are `canceled` and `canceledOrderIds`. Stored order statuses are unchanged.

  • 65020e0: **BREAKING:** `handleStorefrontOrderCancelled` is now `handleStorefrontOrderCanceled`. The bus registration key ends in `order-canceled`, and the stale-order job summary fields are `canceled` and `canceledOrderIds`. Stored order statuses are unchanged.
  • 204ce92: **BREAKING:** these packages now require `@wabbit/tome-economy` 0.18.0 or later, because they use its renamed `SubscriptionCanceledEvent`, `OrderCanceledEvent` and `subscription.canceled` names. Update `@wabbit/tome-economy` in the same change as these packages.
  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.4.0minor

78d9bf7: Honors `staff` from `LayerFactoryConfig`: operations this layer left without an access rule go to the site's staff check. This layer passes a hand-built config to `applyLayerFactoryConfig`, so it now forwards `staff` explicitly. Without that, it would accept the option and silently drop it. Omitting `staff` changes nothing.

  • 78d9bf7: Honors `staff` from `LayerFactoryConfig`: operations this layer left without an access rule go to the site's staff check. This layer passes a hand-built config to `applyLayerFactoryConfig`, so it now forwards `staff` explicitly. Without that, it would accept the option and silently drop it. Omitting `staff` changes nothing.
v0.3.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.0minor

Fixes from the Shelf storefront checkpoint: variant-free products add from a tile, a buyer who backs out of payment keeps their own pieces, and order links and numbers are configurable. - **Fixes from Shelf Checkpoint B.** A product without variants adds to the cart from a tile again: the cart accepts a product's own SKU while the product has no active variant (the rule the restock request already follows) and stores the line with no variant SKU, the same line an add by product makes, so pricing, the reservation and the order read it as before. The listing source's tile `addSku` is now `null` for a product without variants (the tile adds by product alone) and stays the single variant's SKU when there is exactly one; a product with several variants still links to choose. - **Backing out of the payment page no longer locks the buyer's own pieces.** The bag and the checkout step credit the stock a cart already holds through its own pending checkout back to that cart's lines (new `getStorefrontAvailability(payload, skus, config, { credit })` option; public reads never pass it), so a buyer who returns from an abandoned hosted session sees their pieces for sale instead of "sold out" for 30 minutes. The next checkout releases that hold (trail-keyed, idempotent) before it reserves, so the same units are reserved once. The abandoned order stays pending for the sweep; its later cancel releases nothing, and if it is paid after all the existing expired-then-paid path re-reserves or raises a staff task. The reservation guard itself never credits anything. Economy is unchanged. - **Order links and numbers people can use.** New `orders: { viewPath }` config (default `/orders/view`; a site path, refused otherwise) sets the page the emailed order-view links open, so a themed shop can point them at its own page (Shelf: `/themes/shelf/orders/view`). `StorefrontOrderView` gains `reference`, a short stable form of the order id (`storefrontOrderReference` from `./server`: a numeric id as it is, else the last eight characters as `XXXX-XXXX`), used in the confirmation email and subject, the staff notice (with the id beside it) and the lookup email (its links gain an optional `reference`). The token still carries the real id.

  • Fixes from the Shelf storefront checkpoint: variant-free products add from a tile, a buyer who backs out of payment keeps their own pieces, and order links and numbers are configurable. - **Fixes from Shelf Checkpoint B.** A product without variants adds to the cart from a tile again: the cart accepts a product's own SKU while the product has no active variant (the rule the restock request already follows) and stores the line with no variant SKU, the same line an add by product makes, so pricing, the reservation and the order read it as before. The listing source's tile `addSku` is now `null` for a product without variants (the tile adds by product alone) and stays the single variant's SKU when there is exactly one; a product with several variants still links to choose. - **Backing out of the payment page no longer locks the buyer's own pieces.** The bag and the checkout step credit the stock a cart already holds through its own pending checkout back to that cart's lines (new `getStorefrontAvailability(payload, skus, config, { credit })` option; public reads never pass it), so a buyer who returns from an abandoned hosted session sees their pieces for sale instead of "sold out" for 30 minutes. The next checkout releases that hold (trail-keyed, idempotent) before it reserves, so the same units are reserved once. The abandoned order stays pending for the sweep; its later cancel releases nothing, and if it is paid after all the existing expired-then-paid path re-reserves or raises a staff task. The reservation guard itself never credits anything. Economy is unchanged. - **Order links and numbers people can use.** New `orders: { viewPath }` config (default `/orders/view`; a site path, refused otherwise) sets the page the emailed order-view links open, so a themed shop can point them at its own page (Shelf: `/themes/shelf/orders/view`). `StorefrontOrderView` gains `reference`, a short stable form of the order id (`storefrontOrderReference` from `./server`: a numeric id as it is, else the last eight characters as `XXXX-XXXX`), used in the confirmation email and subject, the staff notice (with the id beside it) and the lookup email (its links gain an optional `reference`). The token still carries the real id.
  • 825be1c: Adds the listing data the Shelf storefront needs: filters, facet counts, injected sources, add-by-slug, four optional settings groups and the product story fields. A second minor changeset on one package merges into one bump. - **Listing filters.** `listStorefrontProducts` takes `ids` (explicit products in order; every other filter ignored), `edition` (the value of the attribute named in `storefront-settings.edition.attribute`), `priceBand` (a `listing.priceBands` label or key; an unknown key filters nothing) and `availability: 'in-stock' | 'all'`. Items gain `compareAtCents` (read from the price row), `priceVaries`, `remaining` (only when low) and `variantSkus`. - **Facets.** `facets: true` adds `facets` to the result: one facet per filterable select, multi-select or text attribute, then `price` and `availability` (`in-stock` and `all`). Counts are taken over the filtered set with each facet's own selection removed, and empty options stay listed with a count of 0. Price bands, the availability filter, price sorts and facets run over at most 500 products; above that a price sort falls back to `newest` with `sortDegraded`, and a band, availability filter or facets request is skipped with `filtersDegraded: true`. - **Settings groups, all optional and empty by default.** `edition` (`attribute`, `label`, `current`, `next { name, date, line }`), `afterOrder.steps[]`, `facts.extra[]` and `listing.priceBands[]`. `getStoreFacts` returns `edition`, `afterOrder` and `extra` only when they are set, so an existing store reads exactly as before. - **`storefrontProductFields()`.** One namespaced `story` group (`notes`, `questions`, `inUse`, `folds`) for the site's catalog bridge to append to `createProductCollection()`'s fields. No plugin. - **Injected sources** from `/server`: `storefrontListingSource`, `storefrontAvailabilitySource` and `storefrontFactsSource`. They satisfy catalog-pack's listing and availability source types structurally, so neither package imports the other. The listing source maps products to tiles (price, compare-at, primary and second image, availability, edition, category with an ordered-step number, and a "Back in" line) and counts products per category. - **Add by slug.** `addToCartBySlug` (server), `actions.addToCart({ productSlug })` and `POST /cart` with `{ productSlug }`. An unknown slug is `product-not-found`; every other rule is the add by id. - **Edition on bag lines.** `StorefrontPricedLine` gains an optional `editionLabel` ("Batch 14"), present when `storefront-settings.edition.attribute` is set and the product has a value; a failed read leaves the line without it and never blocks pricing. Polish: **"Featured" is the catalog's manual order.** `listStorefrontProducts({ sort: 'featured' })` now orders by the product's `sortOrder` (lowest first) and then by title, on both the database path and the in-memory path, instead of duplicating `newest` (`-publishedAt`). `newest` is unchanged, and so is a query with no `sort`. Needs the catalog's new `Products.sortOrder` field (see the catalog changeset); products with no value sort among the unplaced, so a store sets the order on the products it wants first.
  • 797562b: Adds back-in-stock alerts: a `storefront-restock-requests` collection, `requestRestockAlert`, signed confirm and unsubscribe links, and the `storefront-restock-notify` job. Double opt-in is off by default. - **Collection.** `storefront-restock-requests` holds one row per `(email, variantSku)` with a `waiting`, `notified` or `cancelled` status, the lower-cased email, an optional user, a server-stamped `requestedAt` and `consentText`, and a `tokenVersion` that revokes one person's links. Every REST and GraphQL operation is staff-only; the public path is the action. A second `waiting` row for the same email and SKU is refused by the collection's `beforeChange` hook. The GDPR registration matches `user` OR `email`, because a guest request has no user. - **Asking.** `requestRestockAlert` (server and `actions`), and `POST /restock` on the route handlers. It always answers the same neutral line, whether the address is new or already waiting, the item exists or not, the honeypot (`website`) was filled or a limit was hit (ten asks per IP and five per address an hour). It stores a request only for an item that cannot be bought right now, and dedupes silently. - **Double opt-in.** `storefront-settings.restock.doubleOptIn` (default off). On, a request is stored unconfirmed, a signed confirmation link is emailed (`GET /restock/confirm`, seven days), and the notifier skips it until confirmed. - **Notifying.** `storefront-restock-notify` runs every 15 minutes (`jobs.restockCron`). For each SKU that is back in stock it emails the oldest waiting requesters, at most `min(available x 3, 200)` per SKU, counting what was already sent for that SKU in the last 24 hours, each with a signed unsubscribe link (`GET /restock/unsubscribe`, one year). A request is marked `notified` before its email is sent, so a failed write cannot cause a duplicate. The job is also in `jobEndpoints` and `STOREFRONT_JOB_PATHS.restockNotify`. - **Unsubscribing.** `unsubscribeRestock` and `confirmRestock` (server and `actions`), and the `restockUnsubscribe` and `restockConfirm` route handlers, which answer a small page with no personal data. Tampered, expired, wrong-purpose and revoked tokens change nothing. - **Config.** `restock: { productPath?, linksBasePath? }` sets the product link in the email (default `/products/<slug>`) and where the link handlers are mounted (default `/api/storefront`). `notify.templates` gains `restockConfirm`; `restockAvailable` was already there. - **Retention.** `storefront-cart-sweep` now also deletes `notified` and `cancelled` requests 30 days after they changed state and `waiting` requests after `restock.waitingTtlDays`, and reports `restockPruned` when it removed any. `createStorefrontLayer` now returns `storefront-restock-requests` in `collections` and a fourth task and cron endpoint; a site that lists the layer's collections or tasks by hand adds them.
v0.2.0minor

64b0eb9: **BREAKING:** Adds checkout, order processing and order lookup; now needs economy 0.15.0, core 1.24.0 and a commerce-family license. The peer ranges tighten to `@wabbit/tome-economy` `>=0.15.0` and `@wabbit/tome-core` `>=1.24.0`, and `wabbit.family` moves from `foundation` to `commerce` (tier `pro`). **Migration:** upgrade economy to 0.15.0 and core to 1.24.0, add `cancelOnExpiry: ['storefront-cart']` to your economy webhook handler, and make sure your license covers the commerce family. - **Checkout.** `checkoutCart` re-prices the cart, applies the store rules and the tax seam, resolves a member or guest buyer and calls economy's `createCartCheckoutAction` with line identities only. Results are `redirect`, `blocked` with reasons, or `out-of-stock` with the short SKUs. - **Stock.** Reservations are made when the hosted session is created and released on cancel, expiry or a full refund before shipping, checked against the stock trail so a replay never over-releases. An order paid after its reservation was released re-reserves; if stock is short the shipment stays pending with a staff task and nothing is committed. - **After payment.** `processPaidStorefrontOrder` writes the shipping address and one pending shipment, checks the cart out, sends the buyer confirmation (with a signed view link) and the staff notice, and raises staff pack tasks. Idempotent. - **Order views and lookup.** `getOrderViewBySession` (read-only, for the success page), `getOrderViewByToken`, `getStorefrontOrderView`, and `requestOrderLookup`, which always answers the same message, is rate-limited per IP and per email, and has a honeypot field. - **Bus handlers and jobs.** The layer registers order complete, cancel and refund handlers, adds the `storefront-reconcile-paid` and `storefront-pending-sweep` tasks, and returns all three jobs as cron endpoints in `jobEndpoints`. The plugin captures the Payload instance in `onInit`; `getPayload` and `setStorefrontPayload` cover setups without the plugin. - **Next.js.** The route handlers gain `checkout`, `lookup` and `order`; the server-action wrappers gain the same operations. - **Fixes found by the money smoke.** A wildcard rate row now stores `shipsTo` as `["*"]` (a bare `"*"` was parsed as JSON by Payload and could never be saved); readers still accept a legacy bare `"*"`. The rate table no longer sets `minRows` at field level, so the guest member can be recorded on a fresh database (the save hook still rejects an explicitly empty table). The checkout route answers 503 for a Stripe failure or a site's `StripeKeyRefusedError`, 429 for a Stripe rate limit, and 400 with shopper copy for `VariantPriceRequiredError`; Stripe error messages are never logged. - **Fixes from the second money smoke.** The rate-table check rejects an empty table only when the save changes it, so the guest-member seed passes on a fresh database (Payload fills `rates: []` from an empty stored document). Stripe errors are recognized by `type` as well as `name` (stripe-node 22 keeps `name` as `Error`), so a rejected key now answers 503 and a rate limit 429. New `StorefrontPaymentsUnavailableError` (root and `./server`): a site's adapter factory throws it when the paid rail is not connected, and the checkout route answers 503 `payments-unavailable`. The cached settings read now expires after five minutes as well as on tag revalidation, and a stored guest member id is checked against the members collection before an order uses it, so a seed or dropped database can no longer leave a stale id on an order. - **Guest member self-heal on sites whose members require a user.** New `guest.resolveUserId(payload)` config: when a stored guest member id is dangling and the layer has to find-or-create the placeholder at runtime, it asks the site for the system user, so the create no longer fails with `ValidationError: User` (checkout answered `guest-disabled`). The member lookup now matches the canonical slug, the alternate `storefront-guest` slug a members collection derives for a system-user row, and the system user itself, so the first-boot member is found instead of duplicated. Sites without the option behave as before.

  • 64b0eb9: **BREAKING:** Adds checkout, order processing and order lookup; now needs economy 0.15.0, core 1.24.0 and a commerce-family license. The peer ranges tighten to `@wabbit/tome-economy` `>=0.15.0` and `@wabbit/tome-core` `>=1.24.0`, and `wabbit.family` moves from `foundation` to `commerce` (tier `pro`). **Migration:** upgrade economy to 0.15.0 and core to 1.24.0, add `cancelOnExpiry: ['storefront-cart']` to your economy webhook handler, and make sure your license covers the commerce family. - **Checkout.** `checkoutCart` re-prices the cart, applies the store rules and the tax seam, resolves a member or guest buyer and calls economy's `createCartCheckoutAction` with line identities only. Results are `redirect`, `blocked` with reasons, or `out-of-stock` with the short SKUs. - **Stock.** Reservations are made when the hosted session is created and released on cancel, expiry or a full refund before shipping, checked against the stock trail so a replay never over-releases. An order paid after its reservation was released re-reserves; if stock is short the shipment stays pending with a staff task and nothing is committed. - **After payment.** `processPaidStorefrontOrder` writes the shipping address and one pending shipment, checks the cart out, sends the buyer confirmation (with a signed view link) and the staff notice, and raises staff pack tasks. Idempotent. - **Order views and lookup.** `getOrderViewBySession` (read-only, for the success page), `getOrderViewByToken`, `getStorefrontOrderView`, and `requestOrderLookup`, which always answers the same message, is rate-limited per IP and per email, and has a honeypot field. - **Bus handlers and jobs.** The layer registers order complete, cancel and refund handlers, adds the `storefront-reconcile-paid` and `storefront-pending-sweep` tasks, and returns all three jobs as cron endpoints in `jobEndpoints`. The plugin captures the Payload instance in `onInit`; `getPayload` and `setStorefrontPayload` cover setups without the plugin. - **Next.js.** The route handlers gain `checkout`, `lookup` and `order`; the server-action wrappers gain the same operations. - **Fixes found by the money smoke.** A wildcard rate row now stores `shipsTo` as `["*"]` (a bare `"*"` was parsed as JSON by Payload and could never be saved); readers still accept a legacy bare `"*"`. The rate table no longer sets `minRows` at field level, so the guest member can be recorded on a fresh database (the save hook still rejects an explicitly empty table). The checkout route answers 503 for a Stripe failure or a site's `StripeKeyRefusedError`, 429 for a Stripe rate limit, and 400 with shopper copy for `VariantPriceRequiredError`; Stripe error messages are never logged. - **Fixes from the second money smoke.** The rate-table check rejects an empty table only when the save changes it, so the guest-member seed passes on a fresh database (Payload fills `rates: []` from an empty stored document). Stripe errors are recognized by `type` as well as `name` (stripe-node 22 keeps `name` as `Error`), so a rejected key now answers 503 and a rate limit 429. New `StorefrontPaymentsUnavailableError` (root and `./server`): a site's adapter factory throws it when the paid rail is not connected, and the checkout route answers 503 `payments-unavailable`. The cached settings read now expires after five minutes as well as on tag revalidation, and a stored guest member id is checked against the members collection before an order uses it, so a seed or dropped database can no longer leave a stale id on an order. - **Guest member self-heal on sites whose members require a user.** New `guest.resolveUserId(payload)` config: when a stored guest member id is dangling and the layer has to find-or-create the placeholder at runtime, it asks the site for the system user, so the create no longer fails with `ValidationError: User` (checkout answered `guest-disabled`). The member lookup now matches the canonical slug, the alternate `storefront-guest` slug a members collection derives for a system-user row, and the system user itself, so the first-boot member is found instead of duplicated. Sites without the option behave as before.
v0.1.0minor

f3ef493: New package: `@wabbit/tome-storefront`, the cart, store-facts and availability layer for physical goods (increment S1, no money path). - **`createStorefrontLayer`:** returns the `storefront-carts` collection, the `storefront-settings` global, the public availability endpoint and the `storefront-cart-sweep` task, plus a `plugin` that appends all four. `adapter`, `publicBaseUrl` and `tokenSecret` are required; cart limits default to 50 lines, quantity 99 and a 30-day guest or 90-day member TTL. `jobs: false` registers no task. - **`./server`:** cart operations resolved from an opaque cookie token (stored as a SHA-256 hash), the guest-to-member merge at sign-in, a re-priced cart, the public availability projection (`in-stock`, `low`, `out`, `untracked`, never raw counts), the browse listing and the cached store facts. - **`./next`:** cookie-aware server actions and route handlers over the server API. Not in this release: checkout, order confirmation and lookup, restock alerts, and the storefront blocks.

  • f3ef493: New package: `@wabbit/tome-storefront`, the cart, store-facts and availability layer for physical goods (increment S1, no money path). - **`createStorefrontLayer`:** returns the `storefront-carts` collection, the `storefront-settings` global, the public availability endpoint and the `storefront-cart-sweep` task, plus a `plugin` that appends all four. `adapter`, `publicBaseUrl` and `tokenSecret` are required; cart limits default to 50 lines, quantity 99 and a 30-day guest or 90-day member TTL. `jobs: false` registers no task. - **`./server`:** cart operations resolved from an opaque cookie token (stored as a SHA-256 hash), the guest-to-member merge at sign-in, a re-priced cart, the public availability projection (`in-stock`, `low`, `out`, `untracked`, never raw counts), the browse listing and the cached store facts. - **`./next`:** cookie-aware server actions and route handlers over the server API. Not in this release: checkout, order confirmation and lookup, restock alerts, and the storefront blocks.

Blocks Storefront Pack

v0.1.4
v0.1.4patch

0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.

  • 0e44d8f: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Entitlement access `canceling`, order, payout and shipment `canceled`, and restock request `canceled` join the existing `cancelling` and `cancelled` options. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view. Documents that hold the old value keep validating and behave as before. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values; a later release switches them and a backfill moves the stored data. If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.
v0.1.3patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.1.2patch

1636ffa: Storefront demos no longer invent site paths: the cart drawer's checkout link, the checkout step's and order lookup's sign-in links and the order confirmation's lookup link are the caller's `placeholderHref` or `#`.

  • 1636ffa: Storefront demos no longer invent site paths: the cart drawer's checkout link, the checkout step's and order lookup's sign-in links and the order confirmation's lookup link are the caller's `placeholderHref` or `#`.
v0.1.1patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.0minor

843277a: New package: `@wabbit/tome-blocks-storefront-pack`, the storefront blocks for a shop that sells physical goods (commerce family, pro tier). This first release carries eight blocks: the cart (the one client cart store, the bag button, the bag drawer and the real Add to bag control), the product page (buy box, store facts and restock alert) and the checkout surfaces (checkout step, order confirmation and order lookup). - **One cart store.** `CartProvider`, `useCart` and `useCartSelector` from `./cart`, over a framework-free controller from `./cart/core`. Add is optimistic and rolls back with a spoken reason when the server refuses; server calls run one at a time; the default transport is `fetch` against the mounted storefront route handlers and re-reads the priced cart after each change. Only identities and quantities leave the browser, never an amount. - **`storefront-cart-trigger`.** The header bag button with a count that rolls like an odometer (`--tome-count-from`, `--tome-count-to`), shown at its end value under reduced motion. Also exported as the `CartTrigger` component for a header's cart slot. - **`storefront-cart-drawer`.** The bag as a native modal dialog: focus trapped, Escape closes it, focus returns to the control that opened it. Lines with quantity steppers and Remove, a free-shipping bar that grows from the last value the visitor saw, a "goes with" row, the shipping country, the subtotal, the tax note and Checkout. The empty bag has its own message and a way back to the shop. - **`storefront-buy-box`.** The product decision: name, price, a variant picker axis by axis (radio groups, with unavailable choices struck through and spoken), quantity, an Add button that carries the price and rolls it when the variant changes, an availability line with the dispatch cutoff, and a phone buy bar that appears once the main button scrolls out of view. The `featured` variant adds an opener, a plate and margin notes. An add sends only identities and a quantity. - **`storefront-store-facts`.** The store's own facts and "After the order" steps in five layouts (`strip`, `assure`, `proof`, `steps`, `line`), from one source, so the strip, the product page, the drawer and the confirmation cannot disagree. - **`storefront-checkout-step`.** The on-site step before payment: the priced bag as a summary, the receipt email for guests, the ship-to country and one button to the secure payment page. It posts `{ email?, shipCountry }` and never an amount; blocked and sold-out answers are shown in words, and failures show fixed safe copy. - **`storefront-order-confirmation`.** The page after payment: "Confirming your payment" while the order settles (polled every 3 seconds for up to a minute), then the paid order with the store's "After the order" steps, step one marked. - **`storefront-order-lookup`.** A guest finds an order with one email field and a honeypot, and always gets the same neutral "Check your email" answer. - **`storefront-restock-alert`.** "Tell me when it is back" for a sold-out piece: one email field, a plain promise (saved with the request as what the person agreed to), a honeypot, and the same neutral "Noted" answer whatever the address. A request that never reached the server says so and keeps the form. It posts only `{ email, productSlug | productId, variantSku, website, disclosure }` to the mounted restock route. It also appears without placing the block: `./adapter` registers it as the Add to bag control's `restock` slot (new `getRestockRenderer`, `renderRestockAlert`) and the sold-out buy box shows it inline (`data-buy-restock`). Needs `@wabbit/tome-storefront` 0.3.0 or later for the route. - **The Add to bag control.** `./adapter` registers it with blocks-core's adapter registry, so catalog tiles and hero chips get a pill that turns into a stepper in place (`data-add-state`: idle, adding, in-bag, error), a link to the product when a variant must be chosen, and a restock slot that holds the restock alert form. One page-level `aria-live` region speaks "Added {title} to your bag. {n} items." - **Hydration.** `./server` fills the drawer's tax note, "After the order" line, shipped-to countries and "goes with" row from the store facts and the shop listing; the buy box's stock and dispatch line from the availability source; the store facts, checkout step (with an optional checkout-context source for signed-in members and a demo banner) and order confirmation from the facts source. It falls back to the authored fields when a source is missing. The order lookup needs no hydration. Needs `@wabbit/tome-blocks-core` 0.38.0 or later (the Add to bag adapter) and `@wabbit/tome-storefront` 0.3.0 or later. - **`storefront-order-lookup` gains an optional `kicker`.** The meta line above the heading ("Orders · no account needed"), rendered as `data-lookup-kicker` in the ask state, only when set. Nothing changes when it is empty. - **The cart drawer's free-shipping line is authored.** New `freeShip.reachedLine` (default "You have free shipping.", localized) replaces the fixed copy shown once the bag qualifies; the `CartDrawer` component takes it as `freeShipReachedLine`. The amounts still come from the store. - **Fixes from Shelf Checkpoint B.** The buy box adds a product without choices by product alone (`variantSku: null`); its own SKU still keys the stock line and the restock form. The cart line is now the one a tile's add makes. - **The order confirmation shows the short order reference** (`order.reference`, falling back to the id with an older storefront) in `data-order-number` and the spoken "Payment confirmed" line, not the raw 24-character id. - **Bag drawer copy follows the authored block.** Authored copy now wins: with the server hydration wired, the store's tax note fills `taxNote` and its "After the order" steps fill `afterLine` only when the author left them empty (before, the store replaced authored copy). The after line is the step labels as one phrase ("Packed by the maker · shipped with tracking · arrives · a care card in the box"), not the steps' longer sentences (new `afterLineFromSteps` from `./server`). New `shippingNote` field (default "Shipping is worked out at checkout.", localized) follows the tax note in `data-cart-tax-note`; stored blocks without it render as before. Each bag line shows the product's edition after its variant (`data-edition-tag` inside `data-line-variant`) when the priced line carries `editionLabel`. The cart drawer's free-shipping field description and two tests stored a cp1252 apostrophe; they are UTF-8 now. - **The featured buy box has a layout.** `featured` now lays itself out on the page grid instead of stacking full width: its root is a subgrid (so the opener's lanes resolve and it no longer overlaps), the plate and the box sit side by side from 768px, and from 1024px the plate runs to the prose line, the box takes the prose track and the margin notes stand in the right marginalia lane; phones stack. New part hooks `data-buy-featured-plate`, `data-buy-featured-box` and `data-buy-featured-notes` (the existing `data-buy-plate` and `data-margin-notes` stay). - **The phone buy bar follows jumps.** The bar is visible exactly when the main Add button's bottom is above the viewport top, read on mount, on scroll and resize (once a frame), on a hash change and on every IntersectionObserver report. Before, it trusted the observer's change reports alone, so a jump past the button never showed the bar and a jump back never hid it. Checkpoint B round 2: - **Restock, compact.** `RestockAlert` takes `size: 'compact'` (and `compactLabel`): a one-line "Email me when it's back", a link to the product when it has an `href` and a button that opens the form in place when it has none, with `data-restock-link` and `data-restock-open` hooks. `renderRestockAlert` passes the size, the label and the product `href` through, and the Add to bag control treats `compact` as `small` everywhere else. - **Store facts: authored wins.** The server hydration of `storefront-store-facts` fills the rows and the steps from the store only when the author left `facts[]` / `steps[]` empty, the rule the bag drawer already follows. A panel the author wrote ("Shipping", "Returns", "Help") is no longer replaced by the store's own wording.

  • 843277a: New package: `@wabbit/tome-blocks-storefront-pack`, the storefront blocks for a shop that sells physical goods (commerce family, pro tier). This first release carries eight blocks: the cart (the one client cart store, the bag button, the bag drawer and the real Add to bag control), the product page (buy box, store facts and restock alert) and the checkout surfaces (checkout step, order confirmation and order lookup). - **One cart store.** `CartProvider`, `useCart` and `useCartSelector` from `./cart`, over a framework-free controller from `./cart/core`. Add is optimistic and rolls back with a spoken reason when the server refuses; server calls run one at a time; the default transport is `fetch` against the mounted storefront route handlers and re-reads the priced cart after each change. Only identities and quantities leave the browser, never an amount. - **`storefront-cart-trigger`.** The header bag button with a count that rolls like an odometer (`--tome-count-from`, `--tome-count-to`), shown at its end value under reduced motion. Also exported as the `CartTrigger` component for a header's cart slot. - **`storefront-cart-drawer`.** The bag as a native modal dialog: focus trapped, Escape closes it, focus returns to the control that opened it. Lines with quantity steppers and Remove, a free-shipping bar that grows from the last value the visitor saw, a "goes with" row, the shipping country, the subtotal, the tax note and Checkout. The empty bag has its own message and a way back to the shop. - **`storefront-buy-box`.** The product decision: name, price, a variant picker axis by axis (radio groups, with unavailable choices struck through and spoken), quantity, an Add button that carries the price and rolls it when the variant changes, an availability line with the dispatch cutoff, and a phone buy bar that appears once the main button scrolls out of view. The `featured` variant adds an opener, a plate and margin notes. An add sends only identities and a quantity. - **`storefront-store-facts`.** The store's own facts and "After the order" steps in five layouts (`strip`, `assure`, `proof`, `steps`, `line`), from one source, so the strip, the product page, the drawer and the confirmation cannot disagree. - **`storefront-checkout-step`.** The on-site step before payment: the priced bag as a summary, the receipt email for guests, the ship-to country and one button to the secure payment page. It posts `{ email?, shipCountry }` and never an amount; blocked and sold-out answers are shown in words, and failures show fixed safe copy. - **`storefront-order-confirmation`.** The page after payment: "Confirming your payment" while the order settles (polled every 3 seconds for up to a minute), then the paid order with the store's "After the order" steps, step one marked. - **`storefront-order-lookup`.** A guest finds an order with one email field and a honeypot, and always gets the same neutral "Check your email" answer. - **`storefront-restock-alert`.** "Tell me when it is back" for a sold-out piece: one email field, a plain promise (saved with the request as what the person agreed to), a honeypot, and the same neutral "Noted" answer whatever the address. A request that never reached the server says so and keeps the form. It posts only `{ email, productSlug | productId, variantSku, website, disclosure }` to the mounted restock route. It also appears without placing the block: `./adapter` registers it as the Add to bag control's `restock` slot (new `getRestockRenderer`, `renderRestockAlert`) and the sold-out buy box shows it inline (`data-buy-restock`). Needs `@wabbit/tome-storefront` 0.3.0 or later for the route. - **The Add to bag control.** `./adapter` registers it with blocks-core's adapter registry, so catalog tiles and hero chips get a pill that turns into a stepper in place (`data-add-state`: idle, adding, in-bag, error), a link to the product when a variant must be chosen, and a restock slot that holds the restock alert form. One page-level `aria-live` region speaks "Added {title} to your bag. {n} items." - **Hydration.** `./server` fills the drawer's tax note, "After the order" line, shipped-to countries and "goes with" row from the store facts and the shop listing; the buy box's stock and dispatch line from the availability source; the store facts, checkout step (with an optional checkout-context source for signed-in members and a demo banner) and order confirmation from the facts source. It falls back to the authored fields when a source is missing. The order lookup needs no hydration. Needs `@wabbit/tome-blocks-core` 0.38.0 or later (the Add to bag adapter) and `@wabbit/tome-storefront` 0.3.0 or later. - **`storefront-order-lookup` gains an optional `kicker`.** The meta line above the heading ("Orders · no account needed"), rendered as `data-lookup-kicker` in the ask state, only when set. Nothing changes when it is empty. - **The cart drawer's free-shipping line is authored.** New `freeShip.reachedLine` (default "You have free shipping.", localized) replaces the fixed copy shown once the bag qualifies; the `CartDrawer` component takes it as `freeShipReachedLine`. The amounts still come from the store. - **Fixes from Shelf Checkpoint B.** The buy box adds a product without choices by product alone (`variantSku: null`); its own SKU still keys the stock line and the restock form. The cart line is now the one a tile's add makes. - **The order confirmation shows the short order reference** (`order.reference`, falling back to the id with an older storefront) in `data-order-number` and the spoken "Payment confirmed" line, not the raw 24-character id. - **Bag drawer copy follows the authored block.** Authored copy now wins: with the server hydration wired, the store's tax note fills `taxNote` and its "After the order" steps fill `afterLine` only when the author left them empty (before, the store replaced authored copy). The after line is the step labels as one phrase ("Packed by the maker · shipped with tracking · arrives · a care card in the box"), not the steps' longer sentences (new `afterLineFromSteps` from `./server`). New `shippingNote` field (default "Shipping is worked out at checkout.", localized) follows the tax note in `data-cart-tax-note`; stored blocks without it render as before. Each bag line shows the product's edition after its variant (`data-edition-tag` inside `data-line-variant`) when the priced line carries `editionLabel`. The cart drawer's free-shipping field description and two tests stored a cp1252 apostrophe; they are UTF-8 now. - **The featured buy box has a layout.** `featured` now lays itself out on the page grid instead of stacking full width: its root is a subgrid (so the opener's lanes resolve and it no longer overlaps), the plate and the box sit side by side from 768px, and from 1024px the plate runs to the prose line, the box takes the prose track and the margin notes stand in the right marginalia lane; phones stack. New part hooks `data-buy-featured-plate`, `data-buy-featured-box` and `data-buy-featured-notes` (the existing `data-buy-plate` and `data-margin-notes` stay). - **The phone buy bar follows jumps.** The bar is visible exactly when the main Add button's bottom is above the viewport top, read on mount, on scroll and resize (once a frame), on a hash change and on every IntersectionObserver report. Before, it trusted the observer's change reports alone, so a jump past the button never showed the bar and a jump back never hid it. Checkpoint B round 2: - **Restock, compact.** `RestockAlert` takes `size: 'compact'` (and `compactLabel`): a one-line "Email me when it's back", a link to the product when it has an `href` and a button that opens the form in place when it has none, with `data-restock-link` and `data-restock-open` hooks. `renderRestockAlert` passes the size, the label and the product `href` through, and the Add to bag control treats `compact` as `small` everywhere else. - **Store facts: authored wins.** The server hydration of `storefront-store-facts` fills the rows and the steps from the store only when the author left `facts[]` / `steps[]` empty, the rule the bag drawer already follows. A panel the author wrote ("Shipping", "Returns", "Help") is no longer replaced by the store's own wording.

Crowdfund

v0.2.3
v0.2.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.2.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.0minor

d8152b2: **BREAKING:** a default changed — see the Migration note below. Crowdfund no longer fails Payload config validation when `@wabbit/tome-catalog` is not installed. A tier's optional reward `product` relationship is now built only when it has a target: an explicit `catalogProductsSlug` string always builds it, `false` omits it, and when unset it targets `catalog-products` only if catalog is registered in the layer registry (otherwise it is omitted with a console warning). **Migration:** a site that uses catalog through its per-collection factories without calling `initCatalog()` or `createCatalogLayer()` before `createCrowdfundLayer()` passes `catalogProductsSlug: 'catalog-products'` to keep the existing column.

  • d8152b2: **BREAKING:** a default changed — see the Migration note below. Crowdfund no longer fails Payload config validation when `@wabbit/tome-catalog` is not installed. A tier's optional reward `product` relationship is now built only when it has a target: an explicit `catalogProductsSlug` string always builds it, `false` omits it, and when unset it targets `catalog-products` only if catalog is registered in the layer registry (otherwise it is omitted with a console warning). **Migration:** a site that uses catalog through its per-collection factories without calling `initCatalog()` or `createCatalogLayer()` before `createCrowdfundLayer()` passes `catalogProductsSlug: 'catalog-products'` to keep the existing column.
  • d8152b2: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Rocket` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
v0.1.3patch

30bdd74: `ownOrAdminRead` now delegate to core's `ownOrCapabilityAccess`, with identical results. This closes the crowdfund/fulfillment near-fork that `assert:no-forked-primitives` flagged.

  • 30bdd74: `ownOrAdminRead` now delegate to core's `ownOrCapabilityAccess`, with identical results. This closes the crowdfund/fulfillment near-fork that `assert:no-forked-primitives` flagged.
  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `resolveRelationId` (public) is kept as a deprecated delegate to `relationIdRaw`, with identical semantics.
v0.1.2patch

0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behavior change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.

  • 0836ef5: Admin gate → core primitive. "Is this user an admin?" was answered five incompatible ways across the platform (2026-09-01 sale-readiness audit §5.2); these four packages carried a deliberate clone of the same pre-`can()` role-string check, crowdfund's and fulfillment's headers both saying "matching economy verbatim". No behavior change is intended for the legacy path, and tests pin it rather than prose asserting it. **crowdfund, fulfillment, rpg** now call `sessionHasCapabilityOrLegacyAdmin()` from `@wabbit/tome-core/auth/repScoping`, and compose the owner-scoped WHERE through `ownershipOrBypass()` from `@wabbit/tome-core/access`. All three declare `@wabbit/tome-core` as a required, explicitly non-optional peer, so these are plain static imports. The legacy path is unchanged: a `roles` array containing 'admin' is an admin, an unauthenticated request is not, and a non-admin session still resolves to `{ [ownerField]: { equals: user.id } }`. Deliberately widened: capability grants (`crowdfund:admin` / `fulfillment:admin` / `rpg:admin`) and core's `superadmin` / `super-admin` legacy aliases now pass too — the point of adopting the shared primitive. The Access functions are async now; Payload's `Access` type has always allowed a `Promise`, and the capability path needs an await. `hasAdminRole` stays exported from crowdfund and fulfillment as a `@deprecated` back-compat shim with byte-identical semantics; `CROWDFUND_ADMIN_CAPABILITY` and `FULFILLMENT_ADMIN_CAPABILITY` are new named exports. **economy** deliberately does NOT adopt the core primitive, and the reason is a constraint rather than an oversight: `@wabbit/tome-core` is a declared OPTIONAL peer here, the README states in two places that core is genuinely optional, and the only core reference in `src/` is a guarded lazy `require()` in `initEconomy`. A static import of a core access primitive from a collection factory would silently convert that optional peer into a required one. Instead the ten inline checks across `Orders` (×3), `Payments` (×2), `Prices` (×4) and `VendorEarnings` (×1) collapse into one internal `isEconomyAdmin()` in `src/access/adminGate.ts`, implementation moved not rewritten, with a written promotion trigger: the day core becomes a required peer of this package, delete the body and delegate. Orders' unique extra `req.user.collection === 'users'` condition is preserved exactly and pinned by a test. New suites: `crowdfund/tests/access.test.ts` (14), `fulfillment/tests/access.test.ts` (16), `economy/tests/admin-gate.test.ts` (11). Existing `collections.test.ts` assertions in crowdfund and fulfillment were updated to await the now-async access results — asserted VALUES unchanged. rpg has no test harness, so its change is covered by typecheck only. The forcing function ships with the consolidation: `eslint.config.mjs` gains a `no-restricted-syntax` warn-ratchet banning hand-rolled `.roles.includes(...)` admin checks in `access/**`, `collections/**` and `*access*.ts`, pointing at `sessionHasCapabilityOrLegacyAdmin` / `can`. The repo-wide count is 0 (down from 13), with three written `eslint-disable` exemptions: the two deprecated back-compat exports and economy's single gate.
  • 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behavior changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
  • 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source already carries.
v0.1.1patch

f4fd273: Raise the tome-core peer floor to >=1.7.0. The /server subpath imports @wabbit/tome-core/jobs at top level, which first exists in core 1.7.0 — the old >=1.0.0 floor let npm install a combination that fails at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED (tsc cannot catch it; found by the first consumer install).

  • f4fd273: Raise the tome-core peer floor to >=1.7.0. The /server subpath imports @wabbit/tome-core/jobs at top level, which first exists in core 1.7.0 — the old >=1.0.0 floor let npm install a combination that fails at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED (tsc cannot catch it; found by the first consumer install).

Affiliates

v0.7.0
v0.7.0minor

6c24919: A program can now pay partners for a sale that arrives as a lead and is paid in several pieces. Everything below is opt-in; a site that sets none of the new config behaves exactly as on 0.6.5. Lead referrals: set `program.leads.enabled` and a partner can register a lead (`registerLead`), or `captureLeadFromCookie` records one from the partner's link. An admin confirms it (`confirmLeadReferral`), declines it (`declineLeadReferral`) or tags one by hand (`createLeadReferral`). Your site stamps `metadata.referralId`, found with `findLeadReferral`, on every order for the lead, and each of those orders earns commission until `earnsUntil` (12 months after confirmation by default). `extendLeadReferral` moves the claim deadline, and `getAffiliateLeads` returns a partner's own leads with what each has earned. The layer stores only a label and a note for a lead, never a prospect's contact details. Tiers: `program.tiers` defines named tiers with a default rate and renewal cap, and `defaultTier` names the one given to new partners. A partner's own rate and cap still win, then the tier's, then the program's. The new `inviteAffiliate` creates an invited partner, and `approveAffiliate` takes an optional `tier`. Renewal attribution: `resolveRenewalAttribution` lets a site whose subscription records are not economy's say which referral a recurring charge belongs to. Renewal commissions now carry `subscriptionRef` and `productType`. The README explains the idempotency key and the first-activation case. Payouts: `program.requireTaxFormBeforePayout` holds a payee whose payout account has no tax form on file (`required`, or no status) out of a payout run; `collected`, `verified` and `not-required` are paid. Held payees are listed in `heldForTaxForm`, and their commissions stay approved for the next run. Two things an upgrading site will notice without opting in. The referral and commission collections gain optional fields and select options (`registered` channel, `pending` status, two rejection reasons, lead fields on referrals, `subscriptionRef` and `productType` on commissions), so a SQL database gets new nullable columns; `manual` stays a valid channel. And `ExpireReferralsSummary` gains `expiredPendingLeads`, which is 0 while lead referrals are off. New exports: types `AffiliateLeadsConfig`, `AffiliateTierConfig`, `RenewalAttributionContext`, `RenewalAttribution` and `AffiliateLead`, and the constant `AFFILIATES_LEADS_DEFAULTS` from the root entry; the lead operations and their argument types, `inviteAffiliate`, `addCalendarMonths`, `HeldPayout` and `RunAffiliatePayoutsSummary` from `@wabbit/tome-affiliates/server`.

  • 6c24919: A program can now pay partners for a sale that arrives as a lead and is paid in several pieces. Everything below is opt-in; a site that sets none of the new config behaves exactly as on 0.6.5. Lead referrals: set `program.leads.enabled` and a partner can register a lead (`registerLead`), or `captureLeadFromCookie` records one from the partner's link. An admin confirms it (`confirmLeadReferral`), declines it (`declineLeadReferral`) or tags one by hand (`createLeadReferral`). Your site stamps `metadata.referralId`, found with `findLeadReferral`, on every order for the lead, and each of those orders earns commission until `earnsUntil` (12 months after confirmation by default). `extendLeadReferral` moves the claim deadline, and `getAffiliateLeads` returns a partner's own leads with what each has earned. The layer stores only a label and a note for a lead, never a prospect's contact details. Tiers: `program.tiers` defines named tiers with a default rate and renewal cap, and `defaultTier` names the one given to new partners. A partner's own rate and cap still win, then the tier's, then the program's. The new `inviteAffiliate` creates an invited partner, and `approveAffiliate` takes an optional `tier`. Renewal attribution: `resolveRenewalAttribution` lets a site whose subscription records are not economy's say which referral a recurring charge belongs to. Renewal commissions now carry `subscriptionRef` and `productType`. The README explains the idempotency key and the first-activation case. Payouts: `program.requireTaxFormBeforePayout` holds a payee whose payout account has no tax form on file (`required`, or no status) out of a payout run; `collected`, `verified` and `not-required` are paid. Held payees are listed in `heldForTaxForm`, and their commissions stay approved for the next run. Two things an upgrading site will notice without opting in. The referral and commission collections gain optional fields and select options (`registered` channel, `pending` status, two rejection reasons, lead fields on referrals, `subscriptionRef` and `productType` on commissions), so a SQL database gets new nullable columns; `manual` stays a valid channel. And `ExpireReferralsSummary` gains `expiredPendingLeads`, which is 0 while lead referrals are off. New exports: types `AffiliateLeadsConfig`, `AffiliateTierConfig`, `RenewalAttributionContext`, `RenewalAttribution` and `AffiliateLead`, and the constant `AFFILIATES_LEADS_DEFAULTS` from the root entry; the lead operations and their argument types, `inviteAffiliate`, `addCalendarMonths`, `HeldPayout` and `RunAffiliatePayoutsSummary` from `@wabbit/tome-affiliates/server`.
v0.6.5patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.6.4patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.6.3patch

e3139f6: Order commissions no longer include shipping: the basis is now the paid total less tax and less the Order's `shippingCents`. Orders without `shippingCents` (every single-item checkout, and Orders from economy releases before the field) read it as 0, so their commission basis is unchanged.

  • e3139f6: Order commissions no longer include shipping: the basis is now the paid total less tax and less the Order's `shippingCents`. Orders without `shippingCents` (every single-item checkout, and Orders from economy releases before the field) read it as 0, so their commission basis is unchanged.
v0.6.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.6.1patch

579cf8d: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.

  • 579cf8d: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.
  • 286ad72: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.6.0minor

aaf7c34: **BREAKING:** the admin operations `approveAffiliate`, `rejectAffiliate`, `releaseFirstPayout`, `reverseCommission`, `syncAffiliatePromotionCode` and `rotateAffiliatePromotionCode` now authorize in-function. Each args object takes the admin's `req` (checked by the new `assertAffiliatesAdmin(req)` — `affiliates:admin` capability, super-admin, or a legacy admin role) or an explicit `system: true` for trusted server code with no session; without either, or for a non-admin session, the call throws the new `AffiliatesAuthorizationError` (code `affiliates-not-authorized`) before any write. Previously the contract was "callers are responsible for checking `isAffiliatesAdmin`", and every write runs with `overrideAccess: true`, so a consumer that wrapped one of these as a server action without its own gate shipped an open money-moving endpoint. Migration: pass `req` (or `system: true`) in each call.

  • aaf7c34: **BREAKING:** the admin operations `approveAffiliate`, `rejectAffiliate`, `releaseFirstPayout`, `reverseCommission`, `syncAffiliatePromotionCode` and `rotateAffiliatePromotionCode` now authorize in-function. Each args object takes the admin's `req` (checked by the new `assertAffiliatesAdmin(req)` — `affiliates:admin` capability, super-admin, or a legacy admin role) or an explicit `system: true` for trusted server code with no session; without either, or for a non-admin session, the call throws the new `AffiliatesAuthorizationError` (code `affiliates-not-authorized`) before any write. Previously the contract was "callers are responsible for checking `isAffiliatesAdmin`", and every write runs with `overrideAccess: true`, so a consumer that wrapped one of these as a server action without its own gate shipped an open money-moving endpoint. Migration: pass `req` (or `system: true`) in each call.
  • 56686d6: Logging goes through `payload.logger`, not stdout. Terms acceptance logs at info WITHOUT the accepting IP (the IP is still stored on the row as consent evidence, never logged); admin payout release / commission reversal notices use `payload.logger.info`; a failed affiliate lookup during checkout attribution now logs a warning instead of silently dropping the order's commission attribution.
  • cbcc20e: Jobs and handlers no longer silently truncate at 1000 (or 5000) rows. `approveMaturedAffiliateCommissions`, `expireAffiliateReferrals`, `purgeAffiliateClicks`, the payout run's approved-commission scan, the velocity review, the refund handler's order-commission scan and the carry-forward sum now page with core's `findPaged`; the renewal period index uses `payload.count`. Independent per-row writes (approve/expire/purge, velocity flag, mark-paid) run through core's bounded-concurrency `batchWrite`; in the three jobs a failing row is logged and counted instead of aborting the run. Job summaries gain optional `failed` and `truncated` fields (a truncated scan converges on the next run). Refund reversals stay sequential on purpose (money movement).
  • 44b39f3: The package-local `relationId` in `server/commissions` (same name as core's, but returning `''` instead of `null`) is removed. Every call site now uses core's `relationId` with an explicit `?? ''`, so behavior is unchanged. The two attribution helpers' private `relationshipId` copies also use core's reader.
v0.5.0minor

e87fdb7: Promotion codes now follow the affiliate's status, and a leaked code can be rotated. - New optional `setPromotionCodeActive({ promotionCodeRef, active })` config callback. The affiliates collection calls it whenever an affiliate crosses into or out of `active`. Pausing or terminating an affiliate deactivates their code in the payment provider, and reinstating them reactivates it. Before this change, a stopped affiliate's code kept giving buyers the discount while crediting nobody. The hook never throws: a provider failure is logged loudly and the status change stands. A missing callback produces a warning. - New `rotateAffiliatePromotionCode` (server entry). It deactivates the current code first, then mints a replacement with different text on the same coupon and links it to the affiliate. Reusing the old text is refused, because it would put the leaked code back into circulation. - Compile-time contract extended: a real Stripe client must fit rotation's structural `promotionCodes.update` / `.create` shape.

  • e87fdb7: Promotion codes now follow the affiliate's status, and a leaked code can be rotated. - New optional `setPromotionCodeActive({ promotionCodeRef, active })` config callback. The affiliates collection calls it whenever an affiliate crosses into or out of `active`. Pausing or terminating an affiliate deactivates their code in the payment provider, and reinstating them reactivates it. Before this change, a stopped affiliate's code kept giving buyers the discount while crediting nobody. The hook never throws: a provider failure is logged loudly and the status change stands. A missing callback produces a warning. - New `rotateAffiliatePromotionCode` (server entry). It deactivates the current code first, then mints a replacement with different text on the same coupon and links it to the affiliate. Reusing the old text is refused, because it would put the leaked code back into circulation. - Compile-time contract extended: a real Stripe client must fit rotation's structural `promotionCodes.update` / `.create` shape.
v0.4.2patch

a7ea106: Fix `syncAffiliatePromotionCode` for current Stripe API versions, and make it idempotent. Stripe API `2025-09-30.clover` removed the top-level `coupon` parameter from promotion-code creation in favor of `promotion: { type: 'coupon', coupon }`. The `stripe` Node SDK v22 pins `2026-04-22.dahlia`. This function still sent the removed parameter, so it would have failed the first time a consumer called it — it had never been called, and its only test used a mock that accepted the old shape. The structural `stripe` argument type changes to match. It is now also idempotent: an affiliate that already has a `promotionCodeRef` is returned without calling Stripe (Stripe rejects a second active code with the same text), and the result carries `created: boolean`. A compile-time contract (`src/server/stripe-promotion-code.contract.ts`, excluded from the build) asserts a real `Stripe` client satisfies the structural shape, so the next SDK change of this kind fails `typecheck` here instead of an approval in production. `stripe` is added as a devDependency for that file only.

  • a7ea106: Fix `syncAffiliatePromotionCode` for current Stripe API versions, and make it idempotent. Stripe API `2025-09-30.clover` removed the top-level `coupon` parameter from promotion-code creation in favor of `promotion: { type: 'coupon', coupon }`. The `stripe` Node SDK v22 pins `2026-04-22.dahlia`. This function still sent the removed parameter, so it would have failed the first time a consumer called it — it had never been called, and its only test used a mock that accepted the old shape. The structural `stripe` argument type changes to match. It is now also idempotent: an affiliate that already has a `promotionCodeRef` is returned without calling Stripe (Stripe rejects a second active code with the same text), and the result carries `created: boolean`. A compile-time contract (`src/server/stripe-promotion-code.contract.ts`, excluded from the build) asserts a real `Stripe` client satisfies the structural shape, so the next SDK change of this kind fails `typecheck` here instead of an approval in production. `stripe` is added as a devDependency for that file only.
v0.4.1patch

Fix `AFFILIATES_LAYER_VERSION` reporting `'0.3.0'` in the 0.4.0 release. 0.4.0 was versioned with a bare `changeset version` instead of the repo's `pnpm version-packages`, which also runs `sync-layer-versions`. So the published package's `package.json` said 0.4.0 while the exported layer-version constant — the value `createAffiliatesLayer` registers the layer under — still said 0.3.0. All 0.4.0 behavior (signer identity, stored IP, terms hash, opt-in manual rail) shipped intact; only the self-reported version was wrong. This release re-syncs the constant. Consumers should take 0.4.1 rather than 0.4.0.

  • Fix `AFFILIATES_LAYER_VERSION` reporting `'0.3.0'` in the 0.4.0 release. 0.4.0 was versioned with a bare `changeset version` instead of the repo's `pnpm version-packages`, which also runs `sync-layer-versions`. So the published package's `package.json` said 0.4.0 while the exported layer-version constant — the value `createAffiliatesLayer` registers the layer under — still said 0.3.0. All 0.4.0 behavior (signer identity, stored IP, terms hash, opt-in manual rail) shipped intact; only the self-reported version was wrong. This release re-syncs the constant. Consumers should take 0.4.1 rather than 0.4.0.
v0.4.0minor

832b8d1: Signer identity, a stored acceptance IP, a terms content hash, and an opt-in manual payout rail. **Why:** the receipt for an agreement that authorizes payouts was weaker than the one a consumer keeps for a confidentiality agreement. `acceptAffiliateTerms` wrote three fields — version and two timestamps — and took an `ip` it logged and threw away. There was no record of _who_ signed (a member relationship, whose profile name the member can edit), _from where_, or _what exactly_ they agreed to (a version string can be re-pointed at edited wording). **`acceptAffiliateTerms` now requires a `signer`** — `{ type: 'individual' | 'business', firstName, lastName, businessLegalName?, country }` — and freezes it onto the affiliate row as `signerType` / `signedFirstName` / `signedLastName` / `businessLegalName` / `country`. Copied, never joined: a record resolving its signatory through a mutable profile field records nothing durable, and it matters more here than on an NDA because US tax reporting is against a payee whose legal name has to match their tax records. `businessLegalName` is required for a business signer and discarded for an individual; `country` must be ISO 3166-1 alpha-2 and is uppercased. **The IP is stored** as `acceptedFromIp` instead of only logged. **`termsHash` is optional on both sides.** Pass `currentTermsHash` in config and `termsHash` at acceptance and a mismatch is refused — the stale-tab case, where a page left open across a revision would otherwise record consent to text that no longer exists. Consumers that do not hash their agreement are unaffected. **Deliberately not captured: any taxpayer identification number.** TINs belong to the payout rail, which already collects tax identity and issues the forms. Holding one here would bring encryption-at-rest duties, breach-notification exposure and a retention policy for data this layer has no need to hold. **BEHAVIOR REMOVED — `startPayoutOnboarding` no longer falls back to the manual rail silently.** It previously resolved `connectAdapter ?? manualAdapter`, so an affiliate in a country no Connect adapter supported landed on a hand-run rail because an adapter happened to be missing. The manual rail is now opt-in: it requires `manualPayoutApprovedBy` / `manualPayoutApprovedAt` on the affiliate row, set by an admin. Without that approval an unsupported country throws, naming the country. An affiliate in that state holds an accepted agreement and an approved status but cannot start onboarding — that is the intended state, not a bug. `startPayoutOnboarding`'s `country` argument is now **optional** and sources from the affiliate row's declared country, so the jurisdiction on the agreement and the jurisdiction the payout is routed for cannot diverge. Passing it explicitly still works as an admin override and logs a warning on mismatch. **Migration:** a single production consumer exists today. Callers of `acceptAffiliateTerms` must add `signer`; callers of `startPayoutOnboarding` may drop `country`. Rows signed before this release keep working — `termsHash` and the signer fields are simply absent on them.

  • 832b8d1: Signer identity, a stored acceptance IP, a terms content hash, and an opt-in manual payout rail. **Why:** the receipt for an agreement that authorizes payouts was weaker than the one a consumer keeps for a confidentiality agreement. `acceptAffiliateTerms` wrote three fields — version and two timestamps — and took an `ip` it logged and threw away. There was no record of _who_ signed (a member relationship, whose profile name the member can edit), _from where_, or _what exactly_ they agreed to (a version string can be re-pointed at edited wording). **`acceptAffiliateTerms` now requires a `signer`** — `{ type: 'individual' | 'business', firstName, lastName, businessLegalName?, country }` — and freezes it onto the affiliate row as `signerType` / `signedFirstName` / `signedLastName` / `businessLegalName` / `country`. Copied, never joined: a record resolving its signatory through a mutable profile field records nothing durable, and it matters more here than on an NDA because US tax reporting is against a payee whose legal name has to match their tax records. `businessLegalName` is required for a business signer and discarded for an individual; `country` must be ISO 3166-1 alpha-2 and is uppercased. **The IP is stored** as `acceptedFromIp` instead of only logged. **`termsHash` is optional on both sides.** Pass `currentTermsHash` in config and `termsHash` at acceptance and a mismatch is refused — the stale-tab case, where a page left open across a revision would otherwise record consent to text that no longer exists. Consumers that do not hash their agreement are unaffected. **Deliberately not captured: any taxpayer identification number.** TINs belong to the payout rail, which already collects tax identity and issues the forms. Holding one here would bring encryption-at-rest duties, breach-notification exposure and a retention policy for data this layer has no need to hold. **BEHAVIOR REMOVED — `startPayoutOnboarding` no longer falls back to the manual rail silently.** It previously resolved `connectAdapter ?? manualAdapter`, so an affiliate in a country no Connect adapter supported landed on a hand-run rail because an adapter happened to be missing. The manual rail is now opt-in: it requires `manualPayoutApprovedBy` / `manualPayoutApprovedAt` on the affiliate row, set by an admin. Without that approval an unsupported country throws, naming the country. An affiliate in that state holds an accepted agreement and an approved status but cannot start onboarding — that is the intended state, not a bug. `startPayoutOnboarding`'s `country` argument is now **optional** and sources from the affiliate row's declared country, so the jurisdiction on the agreement and the jurisdiction the payout is routed for cannot diverge. Passing it explicitly still works as an admin override and logs a warning on mismatch. **Migration:** a single production consumer exists today. Callers of `acceptAffiliateTerms` must add `signer`; callers of `startPayoutOnboarding` may drop `country`. Rows signed before this release keep working — `termsHash` and the signer fields are simply absent on them.
v0.3.0minor

d806a2a: `applyForAffiliate` stores an applicant's own answers and `rejectAffiliate` gives admins a way to turn one down; a rejected applicant can try again after a configurable window. The `affiliates` collection gains five fields: `application` (an admin-visible, read-only array of `{ key, label, value }` answers), `applicationSubmittedAt`, `rejectedAt`, `rejectedBy`, and `rejectionReason` (admin-only, like `notes`). `status` gains `rejected`, and the `termsVersion`-required rule now exempts `rejected` alongside `invited`/`applied` — a rejected applicant never accepted terms, so the row was wrongly unsavable before this change. `applyForAffiliate` takes an optional `application?: AffiliateApplicationAnswer[]` argument and stamps `applicationSubmittedAt` server-side. Its re-application rule replaces the old blanket "member already has an affiliate row" throw for the one case that used to dead-end permanently: a `rejected` row. Once `rejectedAt + program.reapplyAfterDays` has passed (new `AffiliateProgramConfig.reapplyAfterDays`, default 30; `0` means immediately), calling `applyForAffiliate` again for that member UPDATES the same row instead of creating a second one — `applied`, a fresh `appliedAt`/answers, and the rejection fields cleared — with the requested code changed only if it is free. Inside the window it throws, naming the date re-application opens. Every other existing-row status still throws as before. `rejectAffiliate({ payload, cfg, affiliateId, actor, reason? })` mirrors `approveAffiliate`: only from `invited`/`applied` (throws otherwise, naming the status), sets `status: 'rejected'`, stamps `rejectedAt`/`rejectedBy`, and records `rejectionReason` when given. No breaking change to any existing export's signature — `application` and `reason` are both optional, and `reapplyAfterDays` defaults. Consumers on `enrollment: 'invite-only'` are unaffected.

  • d806a2a: `applyForAffiliate` stores an applicant's own answers and `rejectAffiliate` gives admins a way to turn one down; a rejected applicant can try again after a configurable window. The `affiliates` collection gains five fields: `application` (an admin-visible, read-only array of `{ key, label, value }` answers), `applicationSubmittedAt`, `rejectedAt`, `rejectedBy`, and `rejectionReason` (admin-only, like `notes`). `status` gains `rejected`, and the `termsVersion`-required rule now exempts `rejected` alongside `invited`/`applied` — a rejected applicant never accepted terms, so the row was wrongly unsavable before this change. `applyForAffiliate` takes an optional `application?: AffiliateApplicationAnswer[]` argument and stamps `applicationSubmittedAt` server-side. Its re-application rule replaces the old blanket "member already has an affiliate row" throw for the one case that used to dead-end permanently: a `rejected` row. Once `rejectedAt + program.reapplyAfterDays` has passed (new `AffiliateProgramConfig.reapplyAfterDays`, default 30; `0` means immediately), calling `applyForAffiliate` again for that member UPDATES the same row instead of creating a second one — `applied`, a fresh `appliedAt`/answers, and the rejection fields cleared — with the requested code changed only if it is free. Inside the window it throws, naming the date re-application opens. Every other existing-row status still throws as before. `rejectAffiliate({ payload, cfg, affiliateId, actor, reason? })` mirrors `approveAffiliate`: only from `invited`/`applied` (throws otherwise, naming the status), sets `status: 'rejected'`, stamps `rejectedAt`/`rejectedBy`, and records `rejectionReason` when given. No breaking change to any existing export's signature — `application` and `reason` are both optional, and `reapplyAfterDays` defaults. Consumers on `enrollment: 'invite-only'` are unaffected.
v0.2.0minor

d2375ad: `registerAffiliateHandlers` uses economy's keyed registration instead of its own globalThis bookkeeping. The hand-rolled registry entry (a `Symbol.for('@wabbit/tome-affiliates/handlers')` slot holding a bundled unsubscribe, plus an early return on the second call) is replaced by passing a stable `key` to each of the three economy registrations. Same guarantee — a repeat call never leaves a second copy of any handler registered — with the bookkeeping now living in the registry that owns the problem. One semantic shift is deliberate: a repeat call now RE-registers with the config it was handed and returns a fresh unsubscribe, where before it ignored the new config and returned the first call's unsubscribe. Last config wins, which is what you want under HMR and when a later caller passes updated settings. An unsubscribe returned by a superseded call is inert rather than destructive, because economy releases a key only if the handler holding it is still the live one. RELEASE COUPLING — read before publishing. This now calls a two-argument registration API that only exists in the `@wabbit/tome-economy` release carrying keyed registration. The peer floor is currently `>=0.11.0 <1.0.0` and MUST be raised to that release's version. JavaScript does not throw when a second argument is passed to a one-argument function, so an affiliates build resolved against an older economy would ignore `{ key }` and silently fall back to append-every-time — losing the dedupe with no error, which is the exact failure class this pair of changes exists to remove. The floor is intentionally left unbumped here because version numbers belong to the release train, not to this changeset.

  • d2375ad: `registerAffiliateHandlers` uses economy's keyed registration instead of its own globalThis bookkeeping. The hand-rolled registry entry (a `Symbol.for('@wabbit/tome-affiliates/handlers')` slot holding a bundled unsubscribe, plus an early return on the second call) is replaced by passing a stable `key` to each of the three economy registrations. Same guarantee — a repeat call never leaves a second copy of any handler registered — with the bookkeeping now living in the registry that owns the problem. One semantic shift is deliberate: a repeat call now RE-registers with the config it was handed and returns a fresh unsubscribe, where before it ignored the new config and returned the first call's unsubscribe. Last config wins, which is what you want under HMR and when a later caller passes updated settings. An unsubscribe returned by a superseded call is inert rather than destructive, because economy releases a key only if the handler holding it is still the live one. RELEASE COUPLING — read before publishing. This now calls a two-argument registration API that only exists in the `@wabbit/tome-economy` release carrying keyed registration. The peer floor is currently `>=0.11.0 <1.0.0` and MUST be raised to that release's version. JavaScript does not throw when a second argument is passed to a one-argument function, so an affiliates build resolved against an older economy would ignore `{ key }` and silently fall back to append-every-time — losing the dedupe with no error, which is the exact failure class this pair of changes exists to remove. The floor is intentionally left unbumped here because version numbers belong to the release train, not to this changeset.
v0.1.0minor

8473b77: Initial release. A referral attribution and commissions layer built on `@wabbit/tome-economy` 0.11.0's seams and payouts extension (layer spec 2026-09-14). **Collections.** `affiliates`, `affiliate-referrals`, `affiliate-clicks`, and `affiliate-commissions`, all slug-overridable. Admin access uses the `affiliates:admin` capability; an affiliate reads only their own row. GDPR registration: affiliates soft-anonymize, referrals null the referred member, commissions are retained. Terminating an affiliate voids their pending commissions and expires their open referrals. **Attribution.** A signed referral cookie (Web Crypto HMAC, safe on the Edge runtime) that is only set with marketing consent when `requireMarketingConsent` is on, a daily-rotating visitor hash, `captureReferral` for middleware, `bindReferralToMember` at signup (last click wins, self-referral rejected), `resolveCheckoutAttribution` for checkout metadata, and a signed `POST /affiliates/click` endpoint. Promotion codes attribute without a cookie and win over links by default. **Money flow.** Order, subscription-renewal, and order-refund handlers on economy's dispatchers. Commissions snapshot the rate and pay on the amount actually paid before tax, optionally narrowed by `commissionBasis`. Partial refunds reverse proportionally; a refund after payout becomes a negative carry-forward row netted against the next payout. A velocity control flags bursts of conversions from one visitor for review. Four jobs: approve matured commissions, run payouts, expire referrals, purge clicks. Payouts run through economy's `runPayoutBatch`, and each affiliate's first payout waits for admin review. **Wiring.** `createAffiliatesLayer(config)` returns collections, endpoints, jobs, and `registerHandlers`. Handlers reach Payload through `config.getPayload`. Consumers call `assertEconomyPayoutsCollections` and `registerAffiliatePayoutAdapters` from `onInit`. Requires `@wabbit/tome-core` >=1.16.0 and `@wabbit/tome-economy` >=0.11.0. Known deferral: a refunded subscription invoice does not reverse its renewal commission, because economy dispatches no invoice-refund event yet.

  • 8473b77: Initial release. A referral attribution and commissions layer built on `@wabbit/tome-economy` 0.11.0's seams and payouts extension (layer spec 2026-09-14). **Collections.** `affiliates`, `affiliate-referrals`, `affiliate-clicks`, and `affiliate-commissions`, all slug-overridable. Admin access uses the `affiliates:admin` capability; an affiliate reads only their own row. GDPR registration: affiliates soft-anonymize, referrals null the referred member, commissions are retained. Terminating an affiliate voids their pending commissions and expires their open referrals. **Attribution.** A signed referral cookie (Web Crypto HMAC, safe on the Edge runtime) that is only set with marketing consent when `requireMarketingConsent` is on, a daily-rotating visitor hash, `captureReferral` for middleware, `bindReferralToMember` at signup (last click wins, self-referral rejected), `resolveCheckoutAttribution` for checkout metadata, and a signed `POST /affiliates/click` endpoint. Promotion codes attribute without a cookie and win over links by default. **Money flow.** Order, subscription-renewal, and order-refund handlers on economy's dispatchers. Commissions snapshot the rate and pay on the amount actually paid before tax, optionally narrowed by `commissionBasis`. Partial refunds reverse proportionally; a refund after payout becomes a negative carry-forward row netted against the next payout. A velocity control flags bursts of conversions from one visitor for review. Four jobs: approve matured commissions, run payouts, expire referrals, purge clicks. Payouts run through economy's `runPayoutBatch`, and each affiliate's first payout waits for admin review. **Wiring.** `createAffiliatesLayer(config)` returns collections, endpoints, jobs, and `registerHandlers`. Handlers reach Payload through `config.getPayload`. Consumers call `assertEconomyPayoutsCollections` and `registerAffiliatePayoutAdapters` from `onInit`. Requires `@wabbit/tome-core` >=1.16.0 and `@wabbit/tome-economy` >=0.11.0. Known deferral: a refunded subscription invoice does not reverse its renewal commission, because economy dispatches no invoice-refund event yet.

Economy Authnet

v0.3.0
v0.3.0minor

65020e0: **BREAKING:** the mapped subscription webhook event type is now `'subscription.canceled'`, matching `@wabbit/tome-economy`. The Authorize.net event names it reads are unchanged.

  • 65020e0: **BREAKING:** the mapped subscription webhook event type is now `'subscription.canceled'`, matching `@wabbit/tome-economy`. The Authorize.net event names it reads are unchanged.
  • 204ce92: **BREAKING:** these packages now require `@wabbit/tome-economy` 0.18.0 or later, because they use its renamed `SubscriptionCanceledEvent`, `OrderCanceledEvent` and `subscription.canceled` names. Update `@wabbit/tome-economy` in the same change as these packages.
v0.2.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.2patch

302d53a: **Refund accounting is cumulative and replay-safe; checkout completion is idempotent; the order-refunded event carries what a consumer needs to act on it.** 1. **A replayed partial refund, or a second partial, no longer marks the Order fully refunded.** The refund branch of `createStripeWebhookHandler` decided full-or-partial by whether a `succeeded` Payment still existed, and marked that Payment `refunded` on any refund, so the second refund event for an order (a redelivery of the first, or a genuine second partial) always read as full. It now decides by amount: the Order's `refundedCents` takes the provider's cumulative refunded amount (Stripe's charge `amount_refunded`, the larger of what is recorded and what the event reports, never a running sum), and the Order is `refunded` when that reaches or passes `totalCents`, `partially-refunded` when it is above 0 and below. Each applied event id is recorded on the Order, and an event already applied, or one reporting no more than the recorded amount, writes nothing. Status only moves forward (`refunded` stays `refunded`). An event with no refund amount now writes nothing and logs a warning; it used to set a refund status with 0 refunded. 2. **A `checkout.completed` replayed after a refund no longer re-grants.** For an Order already `completed`, `refunded` or `partially-refunded` the branch writes nothing: no status change, no second Payment, no order-complete dispatch, and the `onOrderComplete` option does not run. For a `pending` Order, a Payment already recorded for the same session or Order is reused instead of duplicated. 3. **`OrderRefundedEvent` gains `previousStatus`, `newStatus`, `previousRefundedCents`, `full`, `sourceEventId` and `order`** (the six existing fields are unchanged). `registerOrderRefundHandler` keeps its signature; `onOrderRefundDispatch(order, transition?)` takes an optional second argument (new `OrderRefundTransition` type). A refund handler that throws synchronously is now caught and logged like a rejecting one, and a missing `Order.customer` dispatches `userId: ''` instead of throwing. 4. **`WebhookEvent`'s `refund` member gains three optional fields:** `eventId` (the provider event id; `StripeAdapter` now sets it from the Stripe event), `refundAmountBasis: 'cumulative' | 'delta'` (omitted = cumulative, the previous reading) and `refundId`. A delta event's amount is added to the Order's total, replay-guarded by `eventId` and `refundId`. **Schema changes — Postgres consumers must generate and run a migration before deploying** (`payload migrate:create`, then `payload migrate`): `orders.refundEventIds` (json), `payments.refundedCents` (number, default 0), and a new `partially-refunded` value on the Payments `status` select (an enum change). MongoDB needs nothing. `PaymentStatus` gains `'partially-refunded'`; code that switches exhaustively on it needs the new case. A partial refund now leaves its Payment `partially-refunded`, where it used to become `refunded`. **Consumer follow-up:** a site that watched the Orders collection in its own `afterChange` to react to refunds can move that logic to `registerOrderRefundHandler` and branch on `event.full`. `@wabbit/tome-economy-authnet`: `mapAuthorizeNetWebhookEvent` marks `net.authorize.payment.refund.created` as `refundAmountBasis: 'delta'` (an Authorize.net refund transaction carries that one refund's amount, not a running total) and adds `eventId` (the `notificationId`) and `refundId` (the refund `transId`). With this economy release, two partial refunds on an Authorize.net order now sum, and a redelivered notification is ignored. Upgrade both packages together: an older economy ignores the new fields and keeps its previous behavior, and this economy reading an older authnet's events treats each refund amount as a running total.

  • 302d53a: **Refund accounting is cumulative and replay-safe; checkout completion is idempotent; the order-refunded event carries what a consumer needs to act on it.** 1. **A replayed partial refund, or a second partial, no longer marks the Order fully refunded.** The refund branch of `createStripeWebhookHandler` decided full-or-partial by whether a `succeeded` Payment still existed, and marked that Payment `refunded` on any refund, so the second refund event for an order (a redelivery of the first, or a genuine second partial) always read as full. It now decides by amount: the Order's `refundedCents` takes the provider's cumulative refunded amount (Stripe's charge `amount_refunded`, the larger of what is recorded and what the event reports, never a running sum), and the Order is `refunded` when that reaches or passes `totalCents`, `partially-refunded` when it is above 0 and below. Each applied event id is recorded on the Order, and an event already applied, or one reporting no more than the recorded amount, writes nothing. Status only moves forward (`refunded` stays `refunded`). An event with no refund amount now writes nothing and logs a warning; it used to set a refund status with 0 refunded. 2. **A `checkout.completed` replayed after a refund no longer re-grants.** For an Order already `completed`, `refunded` or `partially-refunded` the branch writes nothing: no status change, no second Payment, no order-complete dispatch, and the `onOrderComplete` option does not run. For a `pending` Order, a Payment already recorded for the same session or Order is reused instead of duplicated. 3. **`OrderRefundedEvent` gains `previousStatus`, `newStatus`, `previousRefundedCents`, `full`, `sourceEventId` and `order`** (the six existing fields are unchanged). `registerOrderRefundHandler` keeps its signature; `onOrderRefundDispatch(order, transition?)` takes an optional second argument (new `OrderRefundTransition` type). A refund handler that throws synchronously is now caught and logged like a rejecting one, and a missing `Order.customer` dispatches `userId: ''` instead of throwing. 4. **`WebhookEvent`'s `refund` member gains three optional fields:** `eventId` (the provider event id; `StripeAdapter` now sets it from the Stripe event), `refundAmountBasis: 'cumulative' | 'delta'` (omitted = cumulative, the previous reading) and `refundId`. A delta event's amount is added to the Order's total, replay-guarded by `eventId` and `refundId`. **Schema changes — Postgres consumers must generate and run a migration before deploying** (`payload migrate:create`, then `payload migrate`): `orders.refundEventIds` (json), `payments.refundedCents` (number, default 0), and a new `partially-refunded` value on the Payments `status` select (an enum change). MongoDB needs nothing. `PaymentStatus` gains `'partially-refunded'`; code that switches exhaustively on it needs the new case. A partial refund now leaves its Payment `partially-refunded`, where it used to become `refunded`. **Consumer follow-up:** a site that watched the Orders collection in its own `afterChange` to react to refunds can move that logic to `registerOrderRefundHandler` and branch on `event.full`. `@wabbit/tome-economy-authnet`: `mapAuthorizeNetWebhookEvent` marks `net.authorize.payment.refund.created` as `refundAmountBasis: 'delta'` (an Authorize.net refund transaction carries that one refund's amount, not a running total) and adds `eventId` (the `notificationId`) and `refundId` (the refund `transId`). With this economy release, two partial refunds on an Authorize.net order now sum, and a redelivered notification is ignored. Upgrade both packages together: an older economy ignores the new fields and keeps its previous behavior, and this economy reading an older authnet's events treats each refund amount as a running total.
v0.2.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.0minor

dde4ed0: **BREAKING:** `cancelSubscription` now rejects a period-end cancel instead of silently canceling at once; `createCardCaptureSession` returns the new profile id; reconciliation no longer skips rows. **Migration:** pass `atPeriodEnd: false` to `cancelSubscription` (and to `cancelSubscriptionAction`, whose default is a soft cancel). Authorize.net ARB can only cancel immediately and its cancel webhook ends access at once, so a soft cancel now throws `AuthorizeNetPeriodEndCancelUnsupportedError` (newly exported) rather than becoming a hard one. - `createCardCaptureSession` returned the caller's `existingCustomerProfileId` (so `undefined` for a first-time subscriber) instead of the customer profile it had just created. - `createArbReconciliationJob` collects every candidate row before downgrading any. A row downgraded to `canceled` leaves the status filter, and paging over the shrinking result set skipped rows for the rest of the run.

  • dde4ed0: **BREAKING:** `cancelSubscription` now rejects a period-end cancel instead of silently canceling at once; `createCardCaptureSession` returns the new profile id; reconciliation no longer skips rows. **Migration:** pass `atPeriodEnd: false` to `cancelSubscription` (and to `cancelSubscriptionAction`, whose default is a soft cancel). Authorize.net ARB can only cancel immediately and its cancel webhook ends access at once, so a soft cancel now throws `AuthorizeNetPeriodEndCancelUnsupportedError` (newly exported) rather than becoming a hard one. - `createCardCaptureSession` returned the caller's `existingCustomerProfileId` (so `undefined` for a first-time subscriber) instead of the customer profile it had just created. - `createArbReconciliationJob` collects every candidate row before downgrading any. A row downgraded to `canceled` leaves the status filter, and paging over the shrinking result set skipped rows for the rest of the run.
v0.1.1patch

b2470a2: ARB reconciliation job: replace the two inline `as CollectionSlug` casts with core's `typedSlug()` (core is already a required peer). No runtime change.

  • b2470a2: ARB reconciliation job: replace the two inline `as CollectionSlug` casts with core's `typedSlug()` (core is already a required peer). No runtime change.
v0.1.0minor

5e7ad1f: New package: `@wabbit/tome-economy-authnet` — an Authorize.net `PaymentProviderAdapter` for `@wabbit/tome-economy`. A generic, high-risk-friendly processor adapter (no cannabis or other vertical-specific logic) for verticals Stripe's own restricted-business policy excludes. - `AuthorizeNetAdapter` implements `provider: 'authorizenet'`, `webhookSignatureHeader: 'X-ANET-Signature'`, `createCheckoutSession` (Accept Hosted one-time), the two-step subscription flow (`createSubscriptionSession` + `finalizeSubscriptionFromProfile`), `createPortalSession` (hosted profile page), `handleWebhook`, `cancelSubscription`, `refund`, `retrieveSession` - `AuthorizeNetClient` — a typed JSON client over `fetch` for the Authorize.net API (no SDK dependency; see README "Why no SDK"), covering customer profiles, Accept Hosted, ARB subscriptions, and transaction refunds, with the JSON gateway's BOM-response quirk and documented field-order requirements handled - Webhook signature verification (`verifyAuthorizeNetSignature`, HMAC-SHA512 via `@wabbit/tome-core/utilities/timingSafeEqual`) and event mapping (`mapAuthorizeNetWebhookEvent`) onto the existing normalized `WebhookEvent` union - `createArbReconciliationJob` — a nightly job that polls `ARBGetSubscriptionStatus` for every active `authorizenet` `Subscriptions` row, since ARB's webhook coverage for silent declines is documented but unverified - No Authorize.net SDK dependency; peers `@wabbit/tome-core`, `@wabbit/tome-economy`, `payload`

  • 5e7ad1f: New package: `@wabbit/tome-economy-authnet` — an Authorize.net `PaymentProviderAdapter` for `@wabbit/tome-economy`. A generic, high-risk-friendly processor adapter (no cannabis or other vertical-specific logic) for verticals Stripe's own restricted-business policy excludes. - `AuthorizeNetAdapter` implements `provider: 'authorizenet'`, `webhookSignatureHeader: 'X-ANET-Signature'`, `createCheckoutSession` (Accept Hosted one-time), the two-step subscription flow (`createSubscriptionSession` + `finalizeSubscriptionFromProfile`), `createPortalSession` (hosted profile page), `handleWebhook`, `cancelSubscription`, `refund`, `retrieveSession` - `AuthorizeNetClient` — a typed JSON client over `fetch` for the Authorize.net API (no SDK dependency; see README "Why no SDK"), covering customer profiles, Accept Hosted, ARB subscriptions, and transaction refunds, with the JSON gateway's BOM-response quirk and documented field-order requirements handled - Webhook signature verification (`verifyAuthorizeNetSignature`, HMAC-SHA512 via `@wabbit/tome-core/utilities/timingSafeEqual`) and event mapping (`mapAuthorizeNetWebhookEvent`) onto the existing normalized `WebhookEvent` union - `createArbReconciliationJob` — a nightly job that polls `ARBGetSubscriptionStatus` for every active `authorizenet` `Subscriptions` row, since ARB's webhook coverage for silent declines is documented but unverified - No Authorize.net SDK dependency; peers `@wabbit/tome-core`, `@wabbit/tome-economy`, `payload`

Crm

v0.10.7
v0.10.7patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v0.10.6patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.10.5patch

4d06ec0: Forms and intake gain an opt-in `contact-only` data policy, per form or site-wide, plus retention policies and GDPR registration for submissions. `standard` forms behave as before. `dataPolicy: 'contact-only'` (on `defineForm`, `defineIntakeForm`, or `policy.default` on `createFormsLayer` and `createIntakeLayer`, with `policy.lock` and `policy.sensitiveTerms`) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms `email` target (use `intake` or `payload`), an unacknowledged custom target or router, and an intake email `htmlBuilder`. It is checked when the form is defined, when the layer is created, and when an admin form is saved. Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; `onIntake` receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook `data-form-data-notice`). The policy reduces what a form asks for and where the answers travel. It does not scan what people type, and it is not a legal or regulatory determination. New exports: `formsRetentionPolicies` and `intakeRetentionPolicies` (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. `@wabbit/tome-crm` takes an optional `dataPolicy` on the intake submission input; with `'contact-only'` the `form-submission` activity writes no summary and `intakeTagFields` is not applied. **Check this when you upgrade:** `forms-submissions` gains an indexed `email` column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on `email`, so core's data export and account erasure include those rows from now on.

  • 4d06ec0: Forms and intake gain an opt-in `contact-only` data policy, per form or site-wide, plus retention policies and GDPR registration for submissions. `standard` forms behave as before. `dataPolicy: 'contact-only'` (on `defineForm`, `defineIntakeForm`, or `policy.default` on `createFormsLayer` and `createIntakeLayer`, with `policy.lock` and `policy.sensitiveTerms`) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms `email` target (use `intake` or `payload`), an unacknowledged custom target or router, and an intake email `htmlBuilder`. It is checked when the form is defined, when the layer is created, and when an admin form is saved. Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; `onIntake` receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook `data-form-data-notice`). The policy reduces what a form asks for and where the answers travel. It does not scan what people type, and it is not a legal or regulatory determination. New exports: `formsRetentionPolicies` and `intakeRetentionPolicies` (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. `@wabbit/tome-crm` takes an optional `dataPolicy` on the intake submission input; with `'contact-only'` the `form-submission` activity writes no summary and `intakeTagFields` is not applied. **Check this when you upgrade:** `forms-submissions` gains an indexed `email` column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on `email`, so core's data export and account erasure include those rows from now on.
v0.10.4patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.10.3patch

9db8354: New `./server-api` subpath: `recordCrmActivityWithDedup`, `resolveOpportunityForDeal` and `onDealStatusChange`, without `./server`'s `server-only` guard, for sister layers that load tome-crm through an unbundled `import()`. That native import never gets the `react-server` condition, so `./server`'s guard always threw there, past the caller's try/catch, and killed the process. `./server` is unchanged; application code keeps using it.

  • 9db8354: New `./server-api` subpath: `recordCrmActivityWithDedup`, `resolveOpportunityForDeal` and `onDealStatusChange`, without `./server`'s `server-only` guard, for sister layers that load tome-crm through an unbundled `import()`. That native import never gets the `react-server` condition, so `./server`'s guard always threw there, past the caller's try/catch, and killed the process. `./server` is unchanged; application code keeps using it.
v0.10.2patch

1fa0201: The contact Overview's summary strip (avatar, role, last contacted, open pipeline, owner) keeps Payload's horizontal gutter, so it no longer sits flush against the admin nav or clips the owner at the window edge.

  • 1fa0201: The contact Overview's summary strip (avatar, role, last contacted, open pipeline, owner) keeps Payload's horizontal gutter, so it no longer sits flush against the admin nav or clips the owner at the window edge.
v0.10.1patch

1b08980: Proposal views read "Opened proposal PROP-2026-0001" on the contact timeline instead of the raw key `proposal-viewed`. - `recordCrmActivityWithDedup` takes an optional `subject` for a new row; without it the subject is still derived from the eventType. - tome-deals passes the proposal number as the subject when it logs a recipient view.

  • 1b08980: Proposal views read "Opened proposal PROP-2026-0001" on the contact timeline instead of the raw key `proposal-viewed`. - `recordCrmActivityWithDedup` takes an optional `subject` for a new row; without it the subject is still derived from the eventType. - tome-deals passes the proposal number as the subject when it logs a recipient view.
v0.10.0minor

0e5f063: **BREAKING:** The contacts default edit view is now the Contact Overview and the opportunities list is now the Pipeline list, backed by new activity and opportunity fields. **Migration:** Opt out of both views with `admin: { overview: false }`. The opportunities list default column `valueCents` is now the virtual `value`; update any saved list configuration. The new fields need a migration on SQL adapters (MongoDB needs none). Run `backfillStageChangedAt` from `@wabbit/tome-crm/scripts` once per consumer (dry run by default). - Activities gain `direction`, `durationMinutes`, `attendees` and `dueAt`; activity types gain an optional `group` and `icon`. - `form-submission` and `proposal-viewed` become built-in activity types (an identical re-definition by a site is ignored, a conflicting one still throws). - Opportunities gain a read-only `stageChangedAt`, stamped on create and on every stage change, and a `deals` join when the new `dealsSlug` option is set. - Intake wiring logs a `form-submission` activity on the matched contact (opt out with `intake: { logActivity: false }`). - `recordCrmActivityWithDedup` accepts an `aggregationKey` override, and the deals bridge marks its send email `outbound`. - New `POST {api}/{activities}/log` endpoint, used by the Contact Overview composer. - `@wabbit/tome-crm/server` adds `getContactOverview`, `getPipelineSummary` and `logContactActivity`; a new `./admin` export carries the view names. - `react`, `next` and `@payloadcms/ui` are new optional peer dependencies.

  • 0e5f063: **BREAKING:** The contacts default edit view is now the Contact Overview and the opportunities list is now the Pipeline list, backed by new activity and opportunity fields. **Migration:** Opt out of both views with `admin: { overview: false }`. The opportunities list default column `valueCents` is now the virtual `value`; update any saved list configuration. The new fields need a migration on SQL adapters (MongoDB needs none). Run `backfillStageChangedAt` from `@wabbit/tome-crm/scripts` once per consumer (dry run by default). - Activities gain `direction`, `durationMinutes`, `attendees` and `dueAt`; activity types gain an optional `group` and `icon`. - `form-submission` and `proposal-viewed` become built-in activity types (an identical re-definition by a site is ignored, a conflicting one still throws). - Opportunities gain a read-only `stageChangedAt`, stamped on create and on every stage change, and a `deals` join when the new `dealsSlug` option is set. - Intake wiring logs a `form-submission` activity on the matched contact (opt out with `intake: { logActivity: false }`). - `recordCrmActivityWithDedup` accepts an `aggregationKey` override, and the deals bridge marks its send email `outbound`. - New `POST {api}/{activities}/log` endpoint, used by the Contact Overview composer. - `@wabbit/tome-crm/server` adds `getContactOverview`, `getPipelineSummary` and `logContactActivity`; a new `./admin` export carries the view names. - `react`, `next` and `@payloadcms/ui` are new optional peer dependencies.
v0.9.0minor

ce24fbe: Opportunity edit view is now tabbed: `name` sits above four unnamed tabs (Deal, Contacts, Notes & activity, Source), so stored data is unchanged and no migration is needed. The Deal tab pairs Stage and Probability, with an Outcome block (Loss Category, Closed At, Close Reason) beneath them on won and lost stages. `closedAt` is read-only in the admin. The Notes & activity tab lists the opportunity's CRM activities through a new `activities` join. New `opportunityTabFields` config seam (`deal`, `contacts`, `notesAndActivity`, `source`) appends fields to the end of a tab; `extraFields.opportunities` still appends at the top level after the tabs.

  • ce24fbe: Opportunity edit view is now tabbed: `name` sits above four unnamed tabs (Deal, Contacts, Notes & activity, Source), so stored data is unchanged and no migration is needed. The Deal tab pairs Stage and Probability, with an Outcome block (Loss Category, Closed At, Close Reason) beneath them on won and lost stages. `closedAt` is read-only in the admin. The Notes & activity tab lists the opportunity's CRM activities through a new `activities` join. New `opportunityTabFields` config seam (`deal`, `contacts`, `notesAndActivity`, `source`) appends fields to the end of a tab; `extraFields.opportunities` still appends at the top level after the tabs.
  • c8195e3: Stage transition guard now throws Payload `ValidationError` (400 with a field-level admin message) instead of a plain `Error` (opaque 500), so moving an opportunity to a lost stage without a loss category is a readable form error.
v0.8.0minor

eaf29f3: New `intakeTagFields` config copies a submitted intake field's value into the matched contact's tags; it is off unless a form has an entry. - **`intakeTagFields: { [formSlug]: { field, prefix? } }`**: for the named form, the value of `field` becomes a contact tag, `prefix` written literally (`{ field: 'path', prefix: 'path:' }` tags a visitor who chose "project" as `path:project`). A multi-choice answer adds one tag per value, a tag the contact already has is not added again, blank values add nothing, and a tag is trimmed to 80 characters. - **`buildIntakeMatchStep`** applies the entry after matching when the match input carries the submission: `match({ email, intake: { formSlug, data } })`. Without `intake`, or for a form with no entry, it behaves exactly as before. - **New `addIntakeTags({ payload, config, contactId, submission })`** on `./server` for a site that calls `matchOrCreateContact` itself. It returns the tags it added and the contact's tag list. - New types `TomeCrmIntakeTagField` and `TomeCrmIntakeSubmissionInput`; `TomeCrmMatchContactInput` gains an optional `intake`. No collection or field changes, so no migration.

  • eaf29f3: New `intakeTagFields` config copies a submitted intake field's value into the matched contact's tags; it is off unless a form has an entry. - **`intakeTagFields: { [formSlug]: { field, prefix? } }`**: for the named form, the value of `field` becomes a contact tag, `prefix` written literally (`{ field: 'path', prefix: 'path:' }` tags a visitor who chose "project" as `path:project`). A multi-choice answer adds one tag per value, a tag the contact already has is not added again, blank values add nothing, and a tag is trimmed to 80 characters. - **`buildIntakeMatchStep`** applies the entry after matching when the match input carries the submission: `match({ email, intake: { formSlug, data } })`. Without `intake`, or for a form with no entry, it behaves exactly as before. - **New `addIntakeTags({ payload, config, contactId, submission })`** on `./server` for a site that calls `matchOrCreateContact` itself. It returns the tags it added and the contact's tag list. - New types `TomeCrmIntakeTagField` and `TomeCrmIntakeSubmissionInput`; `TomeCrmMatchContactInput` gains an optional `intake`. No collection or field changes, so no migration.
v0.7.0minor

5260311: `crm-contacts` create now requires `crm:write`, the same gate as accounts, opportunities and activities. It was open to any authenticated session, so a self-registered visitor could POST contacts over REST. Server-side writers (intake bridge, `matchOrCreateContact`) already use `overrideAccess: true` and are unaffected. A consumer that creates contacts through the REST API or the local API without `overrideAccess` as a non-staff user must grant `crm:write` or pass `overrideAccess`.

  • 5260311: `crm-contacts` create now requires `crm:write`, the same gate as accounts, opportunities and activities. It was open to any authenticated session, so a self-registered visitor could POST contacts over REST. Server-side writers (intake bridge, `matchOrCreateContact`) already use `overrideAccess: true` and are unaffected. A consumer that creates contacts through the REST API or the local API without `overrideAccess` as a non-staff user must grant `crm:write` or pass `overrideAccess`.
v0.6.3patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.6.2patch

07331e1: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.

  • 07331e1: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.
  • c023c45: The layer now loads without `lucide-react` installed; the optional peer supplies only the sidebar icon. The `Briefcase` icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without `lucide-react`. Without it, the admin sidebar shows the nav domain's default icon.
v0.6.1patch

ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.

  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 670d2a1: **`normalizeEmail` adoption on the email fields the 2026-09-01 audit flagged, plus the one swallowed error that had no logger.** Email is the cross-layer join key — forms hands a submission to intake, intake to CRM contact matching, CRM to marketing suppression — and `findContactByEmail` / `matchOrCreateContact` normalize before they query. Any layer that stores a raw address forks the same person into two records at the first hand-off. - **intake** — `intake-submissions.email` (required, indexed, the CRM join column) now normalizes through core's canonical `normalizeEmail` via a new `normalizeEmailField` field hook. Field-level rather than folded into `beforeValidateIntake`, which returns early on anything but a create: an admin retyping an address on update is exactly the case a create-only hook misses. - **forms** — the `emailRecipient` config field gains the same hook, and the email field-type descriptor's `sanitize` stops hand-rolling `v.trim().toLowerCase()` and delegates to `normalizeEmail`. That fork agreed byte-for-byte today, which is the problem: the day the shared helper learns anything, forms silently stops agreeing. (`assert:no-forked-primitives` catches exact-body forks, not inline expressions like this one.) - **crm** — `record-crm-activity-with-dedup.ts`'s account-resolution `catch` was the one swallowed error in the repo's sample with no logger call at all (audit §6). The swallow is correct — an activity row without an account link beats a dropped webhook — but a contacts lookup failing there is normally a slug misconfiguration or a permissions change, and every later activity lands unlinked until someone notices. It now warns through `req.payload.logger.warn` with a `console.warn` fallback, naming the contact and the slug, matching `access/presets.ts` and `hooks/stage-change-dispatch.ts`. Normalization stays deliverable-address preserving (trim + lowercase only; no dot-stripping, no plus-tag removal) — asserted, because the stored value is what gets emailed.
  • 670d2a1: First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7). No behavior changed in marketing or intake; forms and crm ship one behavior change each, described below and covered by the same suites. - **marketing** — `webhooks/verify-utils` gets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check against `node:crypto` as an independent oracle, and a full contract table for `timingSafeEqualHex` (case folding, single-nibble mismatch, length short-circuit, and the fact that it does not validate hex — two empty strings compare equal, so callers must check presence first). Both adapters' verify functions are covered end to end: valid token accepts, one-character change rejects, missing/empty/differently-cased header rejects, malformed URL fails closed, and the unconfigured-secret pass-through is asserted explicitly rather than left implicit. The `secret` (Encharge) vs `webhookSecret` (Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secret `undefined`, which means bypass mode — a rename would open both endpoints silently. Also documents a real Web Crypto/node divergence: an empty secret THROWS rather than signing, which is the fail-closed outcome and is now pinned. - **intake** — `withIntakeAccess` gets the full truth table: `create` denied for everyone including admins (all writes go through `submitIntakeAction` with `overrideAccess: true`), read/update per preset, delete admin-only regardless of preset, plus wrapper semantics (overrides incoming access, shallow clone, defines exactly four keys). The file's `TODO: wire to tome-core capability registry` is untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignores `role`/`_populatedRoles`, so the wiring change arrives as a deliberate diff. - **forms** — `server/targets/webhook` covered for request shape (method, header merge and override, `payloadTransform`) and every failure path (non-2xx with detail, 200-char body truncation, body-read failure, network rejection, non-`Error` throw, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed: `fetch` is called with no `AbortSignal`, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands. - **crm** — `access/presets` covered for all three paths: seeded capability grants, the legacy `admin`/`superadmin`/`super-admin` roles-array fallback, and the bootstrap fallback that opens `crm:read` to any authenticated session. The last one is asserted in both directions — what it opens (read on every collection) and what it still refuses (write, delete) — because an un-seeded production site is running on it. `buildAccountDeleteGuard` and the once-per-process production warning are covered too. All four packages gain a `test` script (`vitest run`) and a vitest devDependency, and are removed from `scripts/assert-test-floor.mjs`'s ALLOWLIST — a stale allowlist entry fails the assert in both directions.
  • 4aeedad: Delegates to `@wabbit/tome-workflow`; local guards deprecated. `@wabbit/tome-workflow` is the extracted canonical home for the status-transition table and the keyed side-effect registry — its own module headers say so, naming deals as the source it was ported from — and none of the three packages that still shipped a copy depended on it (2026-09-01 sale-readiness audit §5.1). All three now declare `@wabbit/tome-workflow` as a required, explicitly non-optional peer (`>=0.1.1 <1.0.0`) with a `workspace:*` devDependency twin, and all three raise their `@wabbit/tome-core` peer floor to `>=1.14.0 <2.0.0` (see the LayerFactoryConfig changeset — the new core subpaths do not exist below it). **deals — full delegation, five functions deprecated.** `defineDealSideEffect`, `replaceDealSideEffect`, `getDealSideEffect`, `getRegisteredSideEffectKeys` and `_resetSideEffectRegistry` are now thin wrappers over `defineWorkflowSideEffect` / `replaceWorkflowSideEffect` / `getWorkflowSideEffect` / `getRegisteredWorkflowSideEffectKeys` / `_resetWorkflowSideEffectRegistry`, each `@deprecated` with sunset at the next major. `findTransition` and `validateWorkflow` likewise wrap workflow's `findTransition` / `validateTransitionTable`. `resolveWorkflow` and `DEFAULT_DEAL_WORKFLOW` are NOT deprecated: the default quote lifecycle is deals' own domain data, and `resolveWorkflow` resolves an artifact type's optional workflow override, a deals concept with no workflow-layer equivalent. Two consequences of the deals delegation are invisible at a call site and are stated in the module headers. First, the side-effect store moves from a module-local `Map` to `globalThis` keyed by `Symbol.for` — a FIX, not a byproduct: deals ships separate ESM and CJS builds, so a handler registered through one instance was invisible through the other, and the transition then advanced with its side effect silently skipped. Workflow's own header names deals' local `Map` as the hazard it deliberately did not repeat. Second, the key namespace is now shared with every other workflow consumer, so a duplicate key across two layers throws at registration instead of quietly shadowing — the intended duplicate policy in both packages. One behavior change to note: `validateWorkflow`'s returned message prefix is now `[tome-workflow]` rather than `[tome-deals]`, because the validator is workflow's; that function shipped with zero call sites and zero tests. **marketing — lookup delegated, three semantics kept local.** The §9 campaign lifecycle is now published (module-scope, not from the barrel) as `MARKETING_CAMPAIGN_TRANSITION_TABLE`, derived from the existing adjacency map so the two cannot disagree, and the allow decision plus the "allowed from here" list come from workflow's `findTransition` / `allowedTransitionsFrom`. The adjacency map is kept as the source it is derived from because a flat table cannot distinguish a deliberately terminal status (`archived`, empty list) from a status absent from the map entirely (data corruption) — this hook has always reported those as two different errors, and collapsing them would turn "your database has an unknown status" into "that transition is not permitted". `buildStageTransitionGuard` is NOT deprecated: it is a Payload `beforeChange` hook factory and workflow ships no hook; `guardedTransition` is a server-side call that owns the write, and adopting it moves the transition out of the collection hook entirely. That is marketing's 1.0 question. **crm — lookup delegated, three semantics kept local, and this is the one that could not be forced.** `buildStageTransitionTable(stageConfig)` projects a `TomeCrmOpportunityStageConfig` onto a `WorkflowTransitionTable`, and the allow decision comes from workflow. Three semantics stay local, each because delegating them would change behavior: (1) a stage that declares NO `allowedTransitions` is UNCONSTRAINED in crm, and a transition table cannot distinguish "no edges declared" from "no edges permitted" — feeding those stages to `findTransition` would turn crm's open-by-default pipeline into a closed one for every consumer whose config declares transitions on some stages and not others; (2) an unknown stage key is a misconfiguration reported as one, ahead of any transition check; (3) the lost-category `lossCategory` requirement is a field-level data rule keyed off a stage's `category`, and hanging it on `WorkflowTransition.guard` would make every consumer of the exported table inherit a crm write-validation rule. The rejection message now also names the legal moves from the previous stage, sourced from `allowedTransitionsFrom` — strictly more diagnostic, same throw conditions. The deals↔CRM cascade re-entrancy handshake is untouched: `skipDealCascadeHooks` (deals `status-transition-guard.ts`) and `tomeCrmSuppressStageDispatch` (crm `advance-opportunity-stage.ts` → `stage-change-dispatch.ts`) behave exactly as the 2026-06-11 cascade-semantics amendment D2 documents. Neither guard's participation in that handshake changed; crm's stage guard never participated in it at all. New suites pinning the delegation, one per package (`tests/workflow-delegation.test.ts`, 12 + 9 + 8 assertions), each spying on the workflow module itself so a future edit that quietly restores a local copy fails a test rather than passing silently — which is exactly how the original fork survived four months of green CI.
v0.6.0minor

7d5657c: Security: the bootstrap `crm:read` / `crm:read:own` fallback — which granted CRM read access to ANY authenticated session whenever a site had not seeded capability grants — is now OFF in production by default. Outside production it still applies so a fresh site works out of the box. A production site that needs the bridge while it seeds grants can opt back in explicitly with `TOME_CRM_BOOTSTRAP_READ_FALLBACK=1`. `crm:write` / `crm:admin` are unchanged. Why minor, not patch: a production deployment that never seeded CRM capabilities and relied on this silent bridge will lose CRM reads for non-admin sessions until it seeds grants or sets the flag. That was a live PII exposure (a public-demo consumer was found relying on it); the loud production warning this package already emitted has been telling operators to seed grants since 0.3.1.

  • 7d5657c: Security: the bootstrap `crm:read` / `crm:read:own` fallback — which granted CRM read access to ANY authenticated session whenever a site had not seeded capability grants — is now OFF in production by default. Outside production it still applies so a fresh site works out of the box. A production site that needs the bridge while it seeds grants can opt back in explicitly with `TOME_CRM_BOOTSTRAP_READ_FALLBACK=1`. `crm:write` / `crm:admin` are unchanged. Why minor, not patch: a production deployment that never seeded CRM capabilities and relied on this silent bridge will lose CRM reads for non-admin sessions until it seeds grants or sets the flag. That was a live PII exposure (a public-demo consumer was found relying on it); the loud production warning this package already emitted has been telling operators to seed grants since 0.3.1.
v0.5.0minor

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: Email lookup paths adopt core's `normalizeEmail` instead of hand-rolled lowercasing: crm's `find-contact-by-email` / `match-or-create-contact`, and deals' `create-from-intake` — the latter was missing `.trim()`, so a padded intake email could fork a duplicate CRM contact.
  • 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.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.4.3patch

Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

  • Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
v0.4.1patch

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.
v0.4.0minor

61af0ea: Add configurable `winTriggerStatus` to the deals->CRM cascade (`TomeCrmConfig`). The linked opportunity advances to its `won` stage when a deal reaches this status. Default is `'accepted'` — unchanged behavior: a signed deal wins the opportunity (the quote/SOW lifecycle). Consumers whose payment is decoupled from signature (e.g. proposals paid via Stripe Checkout) set `winTriggerStatus: 'paid'` so the win — and any spawn-on-won side-effect — fires on payment, not on signature. No behavior change for existing consumers; the `paid` payment-note activity is unaffected.

  • 61af0ea: Add configurable `winTriggerStatus` to the deals->CRM cascade (`TomeCrmConfig`). The linked opportunity advances to its `won` stage when a deal reaches this status. Default is `'accepted'` — unchanged behavior: a signed deal wins the opportunity (the quote/SOW lifecycle). Consumers whose payment is decoupled from signature (e.g. proposals paid via Stripe Checkout) set `winTriggerStatus: 'paid'` so the win — and any spawn-on-won side-effect — fires on payment, not on signature. No behavior change for existing consumers; the `paid` payment-note activity is unaffected.
v0.3.2patch

b027075: Fix two consumer-breaking defects found by a consumer's adoption (first post-0.3.x consumer): 1. **`linkedMember` is now composition-gated on `config.memberSlug`** — previously the contacts factory built the relationship unconditionally with `relationTo: memberSlug ?? 'members'`, throwing `InvalidFieldRelationship` at Payload init for any consumer without a `members` collection. The CRM spec makes member identity consumer-wired and optional; the field now follows the same presence pattern as `sourceSubmission`/`intakeSubmissionsSlug`. Both existing consumers (wabbit-site-core, tome-starter) set `memberSlug: 'members'` explicitly and keep the field unchanged; consumers that omitted it were crashing, so no working configuration changes behavior. 2. **`@wabbit/tome-core` peer floor raised `>=1.0.0` → `>=1.1.0`** — crm's dist imports `@wabbit/tome-core/utilities/normalize`, a subpath only exported from core 1.1.0, so the declared floor produced `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime on core 1.0.x installs.

  • b027075: Fix two consumer-breaking defects found by a consumer's adoption (first post-0.3.x consumer): 1. **`linkedMember` is now composition-gated on `config.memberSlug`** — previously the contacts factory built the relationship unconditionally with `relationTo: memberSlug ?? 'members'`, throwing `InvalidFieldRelationship` at Payload init for any consumer without a `members` collection. The CRM spec makes member identity consumer-wired and optional; the field now follows the same presence pattern as `sourceSubmission`/`intakeSubmissionsSlug`. Both existing consumers (wabbit-site-core, tome-starter) set `memberSlug: 'members'` explicitly and keep the field unchanged; consumers that omitted it were crashing, so no working configuration changes behavior. 2. **`@wabbit/tome-core` peer floor raised `>=1.0.0` → `>=1.1.0`** — crm's dist imports `@wabbit/tome-core/utilities/normalize`, a subpath only exported from core 1.1.0, so the declared floor produced `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime on core 1.0.x installs.
v0.3.1patch

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • 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.
v0.3.0minor

c5175e9: v0.3 consumer seams — promotes the four platform gaps wabbit-site-core's dogfood proved (2026-06-10 audit): - **`extraFields` config seam** — `TomeCrmConfig.extraFields.{contacts,accounts,opportunities,activities}` appends site-specific fields (attribution, lead scoring, …) after platform fields. Retires the consumer-side `withCrmExtensions()` post-processing pattern. - **`onOpportunityStageChange` now fires on every stage transition** — the opportunities collection's afterChange hook is the dispatch point, so admin-UI edits and raw `payload.update` calls dispatch the adapter, not just `advanceOpportunityStage()`. The helper suppresses the hook via request context when its own call-time config carries the adapter, so each transition dispatches exactly once. Does not fire on create. - **`findOpenOpportunityForAccount(payload, accountId, { config })`** — canonical "one open opportunity per account" dedup helper, exported from `/server`. - **`buildDealsCrmBridge(config)`** — ready-made callback for `initDeals({ onStatusChange })` (sent→activity, accepted→won, rejected→lost, paid→payment note). Structurally typed (`CrmDealLike`); no dependency on @wabbit/tome-deals. **`wireDeals` is deprecated** — it was a layer-presence probe that attached nothing (the GAP-3 trap) and will be removed in v1.0.

  • c5175e9: v0.3 consumer seams — promotes the four platform gaps wabbit-site-core's dogfood proved (2026-06-10 audit): - **`extraFields` config seam** — `TomeCrmConfig.extraFields.{contacts,accounts,opportunities,activities}` appends site-specific fields (attribution, lead scoring, …) after platform fields. Retires the consumer-side `withCrmExtensions()` post-processing pattern. - **`onOpportunityStageChange` now fires on every stage transition** — the opportunities collection's afterChange hook is the dispatch point, so admin-UI edits and raw `payload.update` calls dispatch the adapter, not just `advanceOpportunityStage()`. The helper suppresses the hook via request context when its own call-time config carries the adapter, so each transition dispatches exactly once. Does not fire on create. - **`findOpenOpportunityForAccount(payload, accountId, { config })`** — canonical "one open opportunity per account" dedup helper, exported from `/server`. - **`buildDealsCrmBridge(config)`** — ready-made callback for `initDeals({ onStatusChange })` (sent→activity, accepted→won, rejected→lost, paid→payment note). Structurally typed (`CrmDealLike`); no dependency on @wabbit/tome-deals. **`wireDeals` is deprecated** — it was a layer-presence probe that attached nothing (the GAP-3 trap) and will be removed in v1.0.
v0.2.0minor

Add the v0.2 marketing-substrate contract (consumed by `@wabbit/tome-marketing`). All new fields are nullable/optional — non-breaking for existing consumers. - **Suppression state + maintenance helpers:** `markContactUnsubscribed`, `markContactBounced` (soft-bounce threshold with auto-suppress), `clearContactSuppression`, `isContactSendable`, `filterSendableContactIds`. New `crm-contacts` fields `bouncedAt` / `bounceType` / `suppressionReason` / `suppressionSource`, and an `onSuppressionChange` config adapter (carries `source` so consumers can loop-guard provider suppression mirrors). - **Provider-event ingestion:** `recordCrmActivityWithDedup` with aggregate-at-ingest — a composite `aggregationKey` collapses `opened`/`clicked`/`site-visited` per contact/campaign/day, while `sent`/`replied`/`bounced`/`unsubscribed` stay 1:1 — plus a `(provider, externalId)` unique index for idempotent webhook ingestion. New `crm-activities` fields `provider` / `eventType` / `aggregationKey` / `eventCount` / `firstEventAt` / `lastEventAt`, a `buildSuppressionCascadeHook`, and `buildStampLastContactedHook` now skips provider events that are not "we contacted them". NOTE: the event-payload param on `recordCrmActivityWithDedup` is `eventPayload` (not `payload`, which is the Payload instance). - **Optional `crm-activities.campaign` relationship** (config-driven via `activityCampaignSlug`, default `marketing-campaigns`) for campaign attribution — only registered when the slug is set; CRM never imports marketing.

  • Add the v0.2 marketing-substrate contract (consumed by `@wabbit/tome-marketing`). All new fields are nullable/optional — non-breaking for existing consumers. - **Suppression state + maintenance helpers:** `markContactUnsubscribed`, `markContactBounced` (soft-bounce threshold with auto-suppress), `clearContactSuppression`, `isContactSendable`, `filterSendableContactIds`. New `crm-contacts` fields `bouncedAt` / `bounceType` / `suppressionReason` / `suppressionSource`, and an `onSuppressionChange` config adapter (carries `source` so consumers can loop-guard provider suppression mirrors). - **Provider-event ingestion:** `recordCrmActivityWithDedup` with aggregate-at-ingest — a composite `aggregationKey` collapses `opened`/`clicked`/`site-visited` per contact/campaign/day, while `sent`/`replied`/`bounced`/`unsubscribed` stay 1:1 — plus a `(provider, externalId)` unique index for idempotent webhook ingestion. New `crm-activities` fields `provider` / `eventType` / `aggregationKey` / `eventCount` / `firstEventAt` / `lastEventAt`, a `buildSuppressionCascadeHook`, and `buildStampLastContactedHook` now skips provider events that are not "we contacted them". NOTE: the event-payload param on `recordCrmActivityWithDedup` is `eventPayload` (not `payload`, which is the Payload instance). - **Optional `crm-activities.campaign` relationship** (config-driven via `activityCampaignSlug`, default `marketing-campaigns`) for campaign attribution — only registered when the slug is set; CRM never imports marketing.
v0.1.5patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.1.4patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v0.1.2patch

433d892: Phase 1.5 dogfood findings — three bugs surfaced when Wabbit became the layer's first consumer: 1. **`crm-activities` duplicate field name.** Both rich-text `body` and the textarea fallback declared `name: 'body'`, discriminated by `admin.condition`. Payload's sanitizer rejects same-level field-name collisions regardless of conditions, throwing `DuplicateFieldName: 'body'` at config build (no consumer could call `payload generate:types` or boot dev). Renamed the textarea to `summary`. `TomeCrmActivity` type and `recordActivity` helper updated to route input.body to `body` for note/email types and input.summary to `summary` otherwise. 2. **Hook type narrowing fails cross-repo.** Consumers with a populated `payload-types.ts` widen `DataFromCollectionSlug<CollectionSlug>` to a 50+ collection union, hiding `lifecycleStage` / `lastContactedAt` from the dynamic-slug `findByID` results in `cascade-contact-lifecycle` and `stamp-last-contacted`. The `auto-link-account` hook also tripped Wabbit's `noUncheckedIndexedAccess`. Read results now narrow structurally to `{ lifecycleStage?: string }` / `{ lastContactedAt?: string | null }`; update payloads cast through `unknown as never`; array indexing guarded. 3. **`sourceSubmission` field hard-required `intake-submissions` collection.** The relation field on `crm-contacts` and `crm-opportunities` resolved to `config.intakeSubmissionsSlug ?? 'intake-submissions'` unconditionally, throwing Payload init for any consumer without `@wabbit/tome-intake` registered. Field is now conditional: declared only when `intakeSubmissionsSlug` is passed in config. Mirrors the composition-presence pattern used by `@wabbit/tome-lms`. No public API changes outside `TomeCrmActivity` (added `summary?: string | null`) and the `recordActivity` helper (now reads `input.summary` for non-rich types). v0.1.0 had zero published consumers; Wabbit's wiring is being adjusted in the same Phase 1.5 cycle.

  • 433d892: Phase 1.5 dogfood findings — three bugs surfaced when Wabbit became the layer's first consumer: 1. **`crm-activities` duplicate field name.** Both rich-text `body` and the textarea fallback declared `name: 'body'`, discriminated by `admin.condition`. Payload's sanitizer rejects same-level field-name collisions regardless of conditions, throwing `DuplicateFieldName: 'body'` at config build (no consumer could call `payload generate:types` or boot dev). Renamed the textarea to `summary`. `TomeCrmActivity` type and `recordActivity` helper updated to route input.body to `body` for note/email types and input.summary to `summary` otherwise. 2. **Hook type narrowing fails cross-repo.** Consumers with a populated `payload-types.ts` widen `DataFromCollectionSlug<CollectionSlug>` to a 50+ collection union, hiding `lifecycleStage` / `lastContactedAt` from the dynamic-slug `findByID` results in `cascade-contact-lifecycle` and `stamp-last-contacted`. The `auto-link-account` hook also tripped Wabbit's `noUncheckedIndexedAccess`. Read results now narrow structurally to `{ lifecycleStage?: string }` / `{ lastContactedAt?: string | null }`; update payloads cast through `unknown as never`; array indexing guarded. 3. **`sourceSubmission` field hard-required `intake-submissions` collection.** The relation field on `crm-contacts` and `crm-opportunities` resolved to `config.intakeSubmissionsSlug ?? 'intake-submissions'` unconditionally, throwing Payload init for any consumer without `@wabbit/tome-intake` registered. Field is now conditional: declared only when `intakeSubmissionsSlug` is passed in config. Mirrors the composition-presence pattern used by `@wabbit/tome-lms`. No public API changes outside `TomeCrmActivity` (added `summary?: string | null`) and the `recordActivity` helper (now reads `input.summary` for non-rich types). v0.1.0 had zero published consumers; Wabbit's wiring is being adjusted in the same Phase 1.5 cycle.

Deals

v0.8.4
v0.8.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.8.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.8.2patch

9db8354: Deals' optional CRM integration (deal-view logging, opportunity resolution, status cascade) no longer crashes the process: it loads `@wabbit/tome-crm/server-api` instead of `./server`. The unbundled `./server` import hit `server-only`'s throwing module under Node's native loader, and the error escaped the try/catch. The tome-starter demo seed died at "seeding deals" because of it. With a tome-crm that predates `./server-api`, the import fails as an ordinary error and the CRM side is skipped.

  • 9db8354: Deals' optional CRM integration (deal-view logging, opportunity resolution, status cascade) no longer crashes the process: it loads `@wabbit/tome-crm/server-api` instead of `./server`. The unbundled `./server` import hit `server-only`'s throwing module under Node's native loader, and the error escaped the try/catch. The tome-starter demo seed died at "seeding deals" because of it. With a tome-crm that predates `./server-api`, the import fails as an ordinary error and the CRM side is skipped.
v0.8.1patch

1b08980: Proposal views read "Opened proposal PROP-2026-0001" on the contact timeline instead of the raw key `proposal-viewed`. - `recordCrmActivityWithDedup` takes an optional `subject` for a new row; without it the subject is still derived from the eventType. - tome-deals passes the proposal number as the subject when it logs a recipient view.

  • 1b08980: Proposal views read "Opened proposal PROP-2026-0001" on the contact timeline instead of the raw key `proposal-viewed`. - `recordCrmActivityWithDedup` takes an optional `subject` for a new row; without it the subject is still derived from the eventType. - tome-deals passes the proposal number as the subject when it logs a recipient view.
v0.8.0minor

56f143c: **BREAKING:** Deals now record how often and when a recipient viewed them, through new fields that need a database migration on SQL adapters. **Migration:** On SQL adapters generate and run a migration for the new deal fields; MongoDB needs none. Otherwise the change is additive. - New read-only `viewCount`, `firstViewedAt` and `lastViewedAt` deal fields, and a `recordDealView` helper on `./server`. - Recipient opens increment the counter and stamp the dates; staff opens change nothing. - When the CRM bridge is wired, a `proposal-viewed` activity is logged on `customerContact`, folded per deal per day.

  • 56f143c: **BREAKING:** Deals now record how often and when a recipient viewed them, through new fields that need a database migration on SQL adapters. **Migration:** On SQL adapters generate and run a migration for the new deal fields; MongoDB needs none. Otherwise the change is additive. - New read-only `viewCount`, `firstViewedAt` and `lastViewedAt` deal fields, and a `recordDealView` helper on `./server`. - Recipient opens increment the counter and stamp the dates; staff opens change nothing. - When the CRM bridge is wired, a `proposal-viewed` activity is logged on `customerContact`, folded per deal per day.
v0.7.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.7.1patch

bfe6c08: `createDealsLayer({ productCollection: false })` omits the line-item `product` relationship, so Deals runs on a site with no catalog. Before this, the relationship to a missing product collection failed Payload's config validation at startup. Line items keep their name, price and quantity. The default (`'catalog-products'`) and custom slugs are unchanged.

  • bfe6c08: `createDealsLayer({ productCollection: false })` omits the line-item `product` relationship, so Deals runs on a site with no catalog. Before this, the relationship to a missing product collection failed Payload's config validation at startup. Line items keep their name, price and quantity. The default (`'catalog-products'`) and custom slugs are unchanged.
v0.7.0minor

bb354ae: **BREAKING:** `onStatusChange` now fires once on every deal status transition, including draft → sent and accepted → invoiced, not only on accepted, rejected and paid. **Migration:** a callback written for won/lost/paid outcomes only should branch on `deal.status` and `prevStatus` and ignore the rest. `buildDealsCrmBridge` from `@wabbit/tome-crm` needs no change, but it now receives `sent` transitions and records an email activity on the linked contact when a deal is sent; the automatic CRM bridge used when `onStatusChange` is unset does the same. The listener is called after the transition's side effect, on every write path (admin edit, `payload.update`, `advanceDealStatus`), and never on a save that leaves the status unchanged. The built-in `cascadeOpportunityWon` and `cascadeOpportunityLost` side effects are now no-ops and `cascadeOpportunityPaid` only stamps `paidAt`: the listener is no longer called from side effects, so a transition cannot reach it twice. The keys stay registered, so workflows that reference them keep validating.

  • bb354ae: **BREAKING:** `onStatusChange` now fires once on every deal status transition, including draft → sent and accepted → invoiced, not only on accepted, rejected and paid. **Migration:** a callback written for won/lost/paid outcomes only should branch on `deal.status` and `prevStatus` and ignore the rest. `buildDealsCrmBridge` from `@wabbit/tome-crm` needs no change, but it now receives `sent` transitions and records an email activity on the linked contact when a deal is sent; the automatic CRM bridge used when `onStatusChange` is unset does the same. The listener is called after the transition's side effect, on every write path (admin edit, `payload.update`, `advanceDealStatus`), and never on a save that leaves the status unchanged. The built-in `cascadeOpportunityWon` and `cascadeOpportunityLost` side effects are now no-ops and `cascadeOpportunityPaid` only stamps `paidAt`: the listener is no longer called from side effects, so a transition cannot reach it twice. The keys stay registered, so workflows that reference them keep validating.
v0.6.0minor

4f5810b: **BREAKING:** a default changed — see the Migration note below. Status changes made by a direct update or an admin-panel edit now fire their transition side effect, and deal-number counters are admin-only to write. - A status change through `payload.update` or the admin panel validated the transition but never ran its side effect (customer email, invoice number, CRM cascade). The deals collection now fires it once from an `afterChange` hook after the write persists; `advanceDealStatus` still fires it itself and no longer risks a second run. - `deal-number-counters` create/update/delete are now admin-only (`deals:admin`, or the legacy admin role); reads stay open to signed-in users. Deal-number generation writes with `overrideAccess`, so reps creating deals are unaffected. - The line-item `product` relation now defaults to `catalog-products`, `@wabbit/tome-catalog`'s products slug, instead of `products`. **Migration:** a site whose line items point at a collection named `products` without passing `productCollection` sets `productCollection: 'products'`. - Removed the unreachable `@wabbit/tome-print` fallback from print dispatch; `printAdapter` is the only print path.

  • 4f5810b: **BREAKING:** a default changed — see the Migration note below. Status changes made by a direct update or an admin-panel edit now fire their transition side effect, and deal-number counters are admin-only to write. - A status change through `payload.update` or the admin panel validated the transition but never ran its side effect (customer email, invoice number, CRM cascade). The deals collection now fires it once from an `afterChange` hook after the write persists; `advanceDealStatus` still fires it itself and no longer risks a second run. - `deal-number-counters` create/update/delete are now admin-only (`deals:admin`, or the legacy admin role); reads stay open to signed-in users. Deal-number generation writes with `overrideAccess`, so reps creating deals are unaffected. - The line-item `product` relation now defaults to `catalog-products`, `@wabbit/tome-catalog`'s products slug, instead of `products`. **Migration:** a site whose line items point at a collection named `products` without passing `productCollection` sets `productCollection: 'products'`. - Removed the unreachable `@wabbit/tome-print` fallback from print dispatch; `printAdapter` is the only print path.
  • c280e78: Deal and invoice numbering now counts existing numbers with `payload.count()` instead of reading every matching deal. No API or behavior change.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.5.0minor

e044594: Artifact types can opt into a block layout: `hasLayout: true` on a `TomeDealArtifactTypeDefinition` adds a `layout` blocks field (Layout tab) to deals of that type, fed by the new `TomeDealsConfig.layoutBlocks`. Configuring `hasLayout` without `layoutBlocks` throws at config time. `narrativeBody` is unchanged; existing artifact types are unaffected.

  • e044594: Artifact types can opt into a block layout: `hasLayout: true` on a `TomeDealArtifactTypeDefinition` adds a `layout` blocks field (Layout tab) to deals of that type, fed by the new `TomeDealsConfig.layoutBlocks`. Configuring `hasLayout` without `layoutBlocks` throws at config time. `narrativeBody` is unchanged; existing artifact types are unaffected.
v0.4.3patch

6943636: The bootstrap read fallback (any authenticated session passes `deals:read` / `deals:read:own` when the capability registry holds no grant) is now OFF in production by default, matching `@wabbit/tome-crm` 0.6.0. A site mid-migration opts back in explicitly with `TOME_DEALS_BOOTSTRAP_READ_FALLBACK=1`; any other value is not an opt-in. Non-production keeps the bridge open. Production logs one warning per process when access runs on the fallback. Found live on a public demo consumer where self-registered visitors could read deal pricing and embedded customer PII.

  • 6943636: The bootstrap read fallback (any authenticated session passes `deals:read` / `deals:read:own` when the capability registry holds no grant) is now OFF in production by default, matching `@wabbit/tome-crm` 0.6.0. A site mid-migration opts back in explicitly with `TOME_DEALS_BOOTSTRAP_READ_FALLBACK=1`; any other value is not an opt-in. Non-production keeps the bridge open. Production logs one warning per process when access runs on the fallback. Found live on a public demo consumer where self-registered visitors could read deal pricing and embedded customer PII.
v0.4.2patch

ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.

  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 4aeedad: `createKeyedRegistry` in core, and the gate that keeps the next registry anchored. Tome had eleven keyed registries and two implementations of one idea: five anchored their state on `globalThis` via `Symbol.for`, six held a module-local `Map` (2026-09-01 sale-readiness audit §5.1, "same mechanism, half correct"). The half that is wrong is wrong silently. A published package ships separate ESM and CJS builds — distinct module instances with distinct module-local state — so the moment one consumer static-imports one build and another `require()`s the other, or a bundler splits an RSC/SSR/client graph, a module-local `Map` exists twice and a registration made through one is invisible through the other. Nothing throws; the handler just never fires. `layerRegistry` shipped that bug in 2026-05 and moved onto `globalThis` in tome-core 1.0.10, both it and the render registry explain the mechanism at length in their headers, and six registries were written afterwards without it. A comment cannot make the next author read it. **New in core:** `@wabbit/tome-core/registry/createKeyedRegistry` (a NEW exports-map subpath — hence the minor). `createKeyedRegistry<T>(symbolKey, { onDuplicate, validate })` returns `{ register, replace, get, has, list, clear }` over a store anchored at `globalThis[Symbol.for(symbolKey)]`. `onDuplicate` is `'throw'` (default) / `'replace'` / `'ignore'`, chosen to match each migrating registry's CURRENT behavior rather than a preferred one. `replace()` is the explicit override path every throw-on-duplicate registry in the repo already exposed for tests and consumer shadowing. The module's JSDoc carries the full migration recipe for the registries not migrated here. 14 unit tests, including the dual-instantiation proof: two separately-created registries on one key share a store, and the state survives a `vi.resetModules()` re-evaluation of the defining module — a module-local `Map` fails both. **Migrated in core:** `gdpr/registry.ts`. This one had BOTH halves of the defect — a module-local `Map` inside `GdprRegistryImpl`, and absence from core's own `sideEffects` array — while four layers (fulfillment, org, sc, plus consumer sites) register into it by import side effect. Split state meant `runErasure`/`exportUserData` reporting zero rows for collections registered into the other copy; a missing `sideEffects` entry meant a bundler was free to drop the registering module outright. The store is now anchored (`onDuplicate: 'replace'`, matching `registerCollection`'s documented overwrite) and `./dist/gdpr/registry.*` is in `sideEffects`, with a `sideEffectsRationale` block in the manifest recording why each entry is there. The class API is unchanged — same names, arguments, semantics, and `getAll()`'s registration-order guarantee. `unregisterCollection` rebuilds the store minus one key (the helper exposes no per-key delete because nothing else needs one), preserving that order. **Migrated in deals:** both registries. `registry/side-effect-registry.ts` is now a delegation shim over `@wabbit/tome-workflow`'s registry (see the workflow-adoption changeset) — anchored by that route. `registry/artifact-registry.ts` moves onto `createKeyedRegistry`, INCLUDING its `frozen` flag: a freeze applied to one module instance while another still accepted registrations would have enforced the config-time contract in exactly half the process. Public API, throws and messages are unchanged. This registry is populated in `payload.config.ts` and read during collection construction, and under the Payload CLI those are separate module instances — the observable failure was an `artifactType` select with no options and a thrown "Unknown artifact type". **Forcing function:** `scripts/assert-registry-anchoring.mjs` + `pnpm assert:registry-anchoring`, wired into `platform-discipline.yml` immediately after `assert:no-forked-primitives` (source + manifest reading only, so it runs pre-build and fails fast). Any module-scope mutable `Map`/`Set`/instance singleton whose name — or whose FILE name — announces a registry must import `createKeyedRegistry`, contain `Symbol.for(`, or have its built path listed in the package's `sideEffects` array; otherwise it fails with the migration recipe. Before this change it reported 3 violations (core's gdpr registry and both deals registries) and now reports 0. Eight registries are ALLOWLISTED with a written architectural reason each, not a schedule: forms ×3, intake and print are owned by the forms+intake access wave and their file sets are off-limits to this one; `blocks-core/src/registry/index.ts` is the deliberately explicit-instance DESCRIPTOR registry (ARCHITECTURE.md § Three Registry Mechanisms #2 — the registry that genuinely must be one store, the render registry, is separately `Symbol.for`-anchored and passes), and changing it is a twelve-package linked-family decision; blocks-gallery's two are import-side-effect registries its own header already calls "the outlier, not the template", in a package with zero tests, so they migrate in the wave that gives it tests.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
  • 4aeedad: Delegates to `@wabbit/tome-workflow`; local guards deprecated. `@wabbit/tome-workflow` is the extracted canonical home for the status-transition table and the keyed side-effect registry — its own module headers say so, naming deals as the source it was ported from — and none of the three packages that still shipped a copy depended on it (2026-09-01 sale-readiness audit §5.1). All three now declare `@wabbit/tome-workflow` as a required, explicitly non-optional peer (`>=0.1.1 <1.0.0`) with a `workspace:*` devDependency twin, and all three raise their `@wabbit/tome-core` peer floor to `>=1.14.0 <2.0.0` (see the LayerFactoryConfig changeset — the new core subpaths do not exist below it). **deals — full delegation, five functions deprecated.** `defineDealSideEffect`, `replaceDealSideEffect`, `getDealSideEffect`, `getRegisteredSideEffectKeys` and `_resetSideEffectRegistry` are now thin wrappers over `defineWorkflowSideEffect` / `replaceWorkflowSideEffect` / `getWorkflowSideEffect` / `getRegisteredWorkflowSideEffectKeys` / `_resetWorkflowSideEffectRegistry`, each `@deprecated` with sunset at the next major. `findTransition` and `validateWorkflow` likewise wrap workflow's `findTransition` / `validateTransitionTable`. `resolveWorkflow` and `DEFAULT_DEAL_WORKFLOW` are NOT deprecated: the default quote lifecycle is deals' own domain data, and `resolveWorkflow` resolves an artifact type's optional workflow override, a deals concept with no workflow-layer equivalent. Two consequences of the deals delegation are invisible at a call site and are stated in the module headers. First, the side-effect store moves from a module-local `Map` to `globalThis` keyed by `Symbol.for` — a FIX, not a byproduct: deals ships separate ESM and CJS builds, so a handler registered through one instance was invisible through the other, and the transition then advanced with its side effect silently skipped. Workflow's own header names deals' local `Map` as the hazard it deliberately did not repeat. Second, the key namespace is now shared with every other workflow consumer, so a duplicate key across two layers throws at registration instead of quietly shadowing — the intended duplicate policy in both packages. One behavior change to note: `validateWorkflow`'s returned message prefix is now `[tome-workflow]` rather than `[tome-deals]`, because the validator is workflow's; that function shipped with zero call sites and zero tests. **marketing — lookup delegated, three semantics kept local.** The §9 campaign lifecycle is now published (module-scope, not from the barrel) as `MARKETING_CAMPAIGN_TRANSITION_TABLE`, derived from the existing adjacency map so the two cannot disagree, and the allow decision plus the "allowed from here" list come from workflow's `findTransition` / `allowedTransitionsFrom`. The adjacency map is kept as the source it is derived from because a flat table cannot distinguish a deliberately terminal status (`archived`, empty list) from a status absent from the map entirely (data corruption) — this hook has always reported those as two different errors, and collapsing them would turn "your database has an unknown status" into "that transition is not permitted". `buildStageTransitionGuard` is NOT deprecated: it is a Payload `beforeChange` hook factory and workflow ships no hook; `guardedTransition` is a server-side call that owns the write, and adopting it moves the transition out of the collection hook entirely. That is marketing's 1.0 question. **crm — lookup delegated, three semantics kept local, and this is the one that could not be forced.** `buildStageTransitionTable(stageConfig)` projects a `TomeCrmOpportunityStageConfig` onto a `WorkflowTransitionTable`, and the allow decision comes from workflow. Three semantics stay local, each because delegating them would change behavior: (1) a stage that declares NO `allowedTransitions` is UNCONSTRAINED in crm, and a transition table cannot distinguish "no edges declared" from "no edges permitted" — feeding those stages to `findTransition` would turn crm's open-by-default pipeline into a closed one for every consumer whose config declares transitions on some stages and not others; (2) an unknown stage key is a misconfiguration reported as one, ahead of any transition check; (3) the lost-category `lossCategory` requirement is a field-level data rule keyed off a stage's `category`, and hanging it on `WorkflowTransition.guard` would make every consumer of the exported table inherit a crm write-validation rule. The rejection message now also names the legal moves from the previous stage, sourced from `allowedTransitionsFrom` — strictly more diagnostic, same throw conditions. The deals↔CRM cascade re-entrancy handshake is untouched: `skipDealCascadeHooks` (deals `status-transition-guard.ts`) and `tomeCrmSuppressStageDispatch` (crm `advance-opportunity-stage.ts` → `stage-change-dispatch.ts`) behave exactly as the 2026-06-11 cascade-semantics amendment D2 documents. Neither guard's participation in that handshake changed; crm's stage guard never participated in it at all. New suites pinning the delegation, one per package (`tests/workflow-delegation.test.ts`, 12 + 9 + 8 assertions), each spying on the workflow module itself so a future edit that quietly restores a local copy fails a test rather than passing silently — which is exactly how the original fork survived four months of green CI.
v0.4.1patch

Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).

  • Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).
v0.4.0minor

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: Email lookup paths adopt core's `normalizeEmail` instead of hand-rolled lowercasing: crm's `find-contact-by-email` / `match-or-create-contact`, and deals' `create-from-intake` — the latter was missing `.trim()`, so a padded intake email could fork a duplicate CRM contact.
  • 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.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.3.1patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
v0.2.3patch

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.
v0.2.2patch

Revert the 0.2.1 `access.create` gating on `total` — it broke the create form. 0.2.1 added `access.create` to make the itemized `total` read-only on the create form, but Payload evaluates field access once at form-init with empty data and does NOT re-evaluate it reactively as the artifact type is selected (unlike `admin.condition`). The result was `total` disabled for ALL types on the create form — including flat-amount proposals, breaking proposal entry. This restores 0.2.0 behavior: `total` editable on the create form for every shown type, correctly gated read-only on the edit form via `access.update`. For itemized types the create-form editability is cosmetic — `computeDealTotalsHook` overwrites the value on save, so no incorrect total can persist. A code comment documents why create is intentionally ungated (fully-correct reactive gating would need a custom client Field component).

  • Revert the 0.2.1 `access.create` gating on `total` — it broke the create form. 0.2.1 added `access.create` to make the itemized `total` read-only on the create form, but Payload evaluates field access once at form-init with empty data and does NOT re-evaluate it reactively as the artifact type is selected (unlike `admin.condition`). The result was `total` disabled for ALL types on the create form — including flat-amount proposals, breaking proposal entry. This restores 0.2.0 behavior: `total` editable on the create form for every shown type, correctly gated read-only on the edit form via `access.update`. For itemized types the create-form editability is cosmetic — `computeDealTotalsHook` overwrites the value on save, so no incorrect total can persist. A code comment documents why create is intentionally ungated (fully-correct reactive gating would need a custom client Field component).
v0.2.1patch

Fix flat-amount `total` editability on the **create** form. `hasFlatAmount` gated `total` editability via field `access.update` only, but the create form is governed by `access.create` — so an itemized type's `total` rendered editable on create (cosmetic: the compute-totals hook overwrites it on save, but incorrect). Now gates both `create` and `update` symmetrically: `total` is editable iff the artifact type is `hasFlatAmount`, on both forms.

  • Fix flat-amount `total` editability on the **create** form. `hasFlatAmount` gated `total` editability via field `access.update` only, but the create form is governed by `access.create` — so an itemized type's `total` rendered editable on create (cosmetic: the compute-totals hook overwrites it on save, but incorrect). Now gates both `create` and `update` symmetrically: `total` is editable iff the artifact type is `hasFlatAmount`, on both forms.
v0.2.0minor

2493781: Add `hasFlatAmount` artifact-type flag for priced-but-not-itemized deals. Artifact types with `hasFlatAmount: true` (e.g. proposals, retainer agreements) now expose an **editable `total`** field that the human sets directly, instead of the line-items-computed total. This fills the gap where a `hasLineItems: false` artifact had no price field at all — `total` is the field downstream charge/email flows read. - Mutually exclusive with `hasLineItems` (a type is either itemized or flat-priced). - Editability is enforced via field-level `access.update` (Payload v3 `admin.readOnly` is boolean-only): editable for flat-amount types, read-only for itemized types where `computeDealTotalsHook` remains the source of truth. - No change to `computeDealTotalsHook` — it already skips any artifact type with `hasLineItems !== true`, so a flat-amount total is never clobbered.

  • 2493781: Add `hasFlatAmount` artifact-type flag for priced-but-not-itemized deals. Artifact types with `hasFlatAmount: true` (e.g. proposals, retainer agreements) now expose an **editable `total`** field that the human sets directly, instead of the line-items-computed total. This fills the gap where a `hasLineItems: false` artifact had no price field at all — `total` is the field downstream charge/email flows read. - Mutually exclusive with `hasLineItems` (a type is either itemized or flat-priced). - Editability is enforced via field-level `access.update` (Payload v3 `admin.readOnly` is boolean-only): editable for flat-amount types, read-only for itemized types where `computeDealTotalsHook` remains the source of truth. - No change to `computeDealTotalsHook` — it already skips any artifact type with `hasLineItems !== true`, so a flat-amount total is never clobbered.
v0.1.3patch

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • 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.
v0.1.2patch

c5175e9: Peer ranges widened — `@wabbit/tome-core`, `@wabbit/tome-crm`, and `@wabbit/tome-catalog` peers are now explicit semver ranges instead of `workspace:*` (which published as exact-version pins, breaking installs whenever a substrate package bumped, e.g. tome-crm 0.2.0 → 0.3.0).

  • c5175e9: Peer ranges widened — `@wabbit/tome-core`, `@wabbit/tome-crm`, and `@wabbit/tome-catalog` peers are now explicit semver ranges instead of `workspace:*` (which published as exact-version pins, breaking installs whenever a substrate package bumped, e.g. tome-crm 0.2.0 → 0.3.0).
v0.1.1patch

Auto-bridge the deal→CRM opportunity cascade. The `cascadeOpportunityWon/Lost/Paid` side-effects previously only forwarded to `config.onStatusChange`; when a consumer registered `@wabbit/tome-crm` but didn't wire `onStatusChange`, the cascade was a silent no-op (accepting/rejecting/paying a deal never advanced the linked opportunity). They now fall back to a new `dispatchDealStatusChangeToCrm` (integration/crm.ts) that lazy-imports CRM's `onDealStatusChange` and runs the cascade whenever CRM is registered. A consumer-provided `onStatusChange` still takes precedence (no double-fire); CRM-absent stays a clean no-op. Consumers with a custom opportunity stage pipeline should still wire `onStatusChange` explicitly. No API/schema change — purely additive defensive wiring.

  • Auto-bridge the deal→CRM opportunity cascade. The `cascadeOpportunityWon/Lost/Paid` side-effects previously only forwarded to `config.onStatusChange`; when a consumer registered `@wabbit/tome-crm` but didn't wire `onStatusChange`, the cascade was a silent no-op (accepting/rejecting/paying a deal never advanced the linked opportunity). They now fall back to a new `dispatchDealStatusChangeToCrm` (integration/crm.ts) that lazy-imports CRM's `onDealStatusChange` and runs the cascade whenever CRM is registered. A consumer-provided `onStatusChange` still takes precedence (no double-fire); CRM-absent stays a clean no-op. Consumers with a custom opportunity stage pipeline should still wire `onStatusChange` explicitly. No API/schema change — purely additive defensive wiring.
v0.1.0minor

Initial release of the Tome Deals layer — a deal-lifecycle engine with a config-time artifact-type registry (quotes, proposals, SOWs, retainer agreements), per-artifact-type field gating, a default `draft → sent → accepted → invoiced → paid` workflow, and CRM cascade integration. - **Artifact-type registry:** `defineDealArtifactType` registers types before `initDeals(...)`; the default `quote` artifact is preregistered. `artifactType` is a `select` frozen from the registry at config time, driving per-type field visibility via Payload `condition` clauses. - **Two collections:** `deals` (the lifecycle collection with per-artifact-type field gating) and `deal-number-counters` (independent per-artifact-type numbering, e.g. `PROP-2026-0001`, `SOW-2026-0001`). - **Side-effect engine:** five built-in side-effect handlers (`send-deal-email`, `stamp-invoice-number`, `cascade-opportunity-won/lost/paid`); the CRM cascade fires `advanceOpportunityStage` via the deals→crm integration adapter only when `@wabbit/tome-crm` is registered (deals fires `onStatusChange`; crm owns `onDealStatusChange`, no double-fire). - **Server helpers + libs** generalized from a shipped consumer's `Quotes.ts`: `computeDealTotals`, `generateDealNumber`, `advanceDealStatus`, `getDeal`, `createDealFromIntake`, Resend-or-log email send. - **Optional integrations** (`crm`, `territory`, `print`) lazy-loaded via `layerRegistry`; `@wabbit/tome-crm` and `@wabbit/tome-catalog` are optional peers. - **Legacy compat:** `./legacy/quote-types` subpath re-exports `TomeQuote*` aliases for one minor version to ease the migration from that consumer.

  • Initial release of the Tome Deals layer — a deal-lifecycle engine with a config-time artifact-type registry (quotes, proposals, SOWs, retainer agreements), per-artifact-type field gating, a default `draft → sent → accepted → invoiced → paid` workflow, and CRM cascade integration. - **Artifact-type registry:** `defineDealArtifactType` registers types before `initDeals(...)`; the default `quote` artifact is preregistered. `artifactType` is a `select` frozen from the registry at config time, driving per-type field visibility via Payload `condition` clauses. - **Two collections:** `deals` (the lifecycle collection with per-artifact-type field gating) and `deal-number-counters` (independent per-artifact-type numbering, e.g. `PROP-2026-0001`, `SOW-2026-0001`). - **Side-effect engine:** five built-in side-effect handlers (`send-deal-email`, `stamp-invoice-number`, `cascade-opportunity-won/lost/paid`); the CRM cascade fires `advanceOpportunityStage` via the deals→crm integration adapter only when `@wabbit/tome-crm` is registered (deals fires `onStatusChange`; crm owns `onDealStatusChange`, no double-fire). - **Server helpers + libs** generalized from a shipped consumer's `Quotes.ts`: `computeDealTotals`, `generateDealNumber`, `advanceDealStatus`, `getDeal`, `createDealFromIntake`, Resend-or-log email send. - **Optional integrations** (`crm`, `territory`, `print`) lazy-loaded via `layerRegistry`; `@wabbit/tome-crm` and `@wabbit/tome-catalog` are optional peers. - **Legacy compat:** `./legacy/quote-types` subpath re-exports `TomeQuote*` aliases for one minor version to ease the migration from that consumer.

Intake

v0.6.0
v0.6.0minor

f321f0b: A new `field` routing strategy sends a submission to an assignee chosen by the value of one form field, for example "party" to the events desk and "rental" to the front desk. Set `routing: { strategy: 'field', fieldRouting: { fieldName, map, fallbackAssigneeId } }`. Values are compared trimmed and lower-cased, and a multi-select uses its first value. A value that isn't in the map goes to the fallback when one is set (recorded as `field-fallback` so staff can see it fired) and is otherwise unrouted. `defineIntakeForm` checks at load that the field name is a field of the form, the map isn't empty, and no two keys collide once lower-cased. `TomeIntakeRoutingStrategy` gains the `field` member, so code that switches on it exhaustively needs a new case. The `custom` strategy is unchanged.

  • f321f0b: A new `field` routing strategy sends a submission to an assignee chosen by the value of one form field, for example "party" to the events desk and "rental" to the front desk. Set `routing: { strategy: 'field', fieldRouting: { fieldName, map, fallbackAssigneeId } }`. Values are compared trimmed and lower-cased, and a multi-select uses its first value. A value that isn't in the map goes to the fallback when one is set (recorded as `field-fallback` so staff can see it fired) and is otherwise unrouted. `defineIntakeForm` checks at load that the field name is a field of the form, the map isn't empty, and no two keys collide once lower-cased. `TomeIntakeRoutingStrategy` gains the `field` member, so code that switches on it exhaustively needs a new case. The `custom` strategy is unchanged.
v0.5.1patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.5.0minor

4d06ec0: Forms and intake gain an opt-in `contact-only` data policy, per form or site-wide, plus retention policies and GDPR registration for submissions. `standard` forms behave as before. `dataPolicy: 'contact-only'` (on `defineForm`, `defineIntakeForm`, or `policy.default` on `createFormsLayer` and `createIntakeLayer`, with `policy.lock` and `policy.sensitiveTerms`) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms `email` target (use `intake` or `payload`), an unacknowledged custom target or router, and an intake email `htmlBuilder`. It is checked when the form is defined, when the layer is created, and when an admin form is saved. Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; `onIntake` receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook `data-form-data-notice`). The policy reduces what a form asks for and where the answers travel. It does not scan what people type, and it is not a legal or regulatory determination. New exports: `formsRetentionPolicies` and `intakeRetentionPolicies` (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. `@wabbit/tome-crm` takes an optional `dataPolicy` on the intake submission input; with `'contact-only'` the `form-submission` activity writes no summary and `intakeTagFields` is not applied. **Check this when you upgrade:** `forms-submissions` gains an indexed `email` column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on `email`, so core's data export and account erasure include those rows from now on.

  • 4d06ec0: Forms and intake gain an opt-in `contact-only` data policy, per form or site-wide, plus retention policies and GDPR registration for submissions. `standard` forms behave as before. `dataPolicy: 'contact-only'` (on `defineForm`, `defineIntakeForm`, or `policy.default` on `createFormsLayer` and `createIntakeLayer`, with `policy.lock` and `policy.sensitiveTerms`) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms `email` target (use `intake` or `payload`), an unacknowledged custom target or router, and an intake email `htmlBuilder`. It is checked when the form is defined, when the layer is created, and when an admin form is saved. Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; `onIntake` receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook `data-form-data-notice`). The policy reduces what a form asks for and where the answers travel. It does not scan what people type, and it is not a legal or regulatory determination. New exports: `formsRetentionPolicies` and `intakeRetentionPolicies` (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. `@wabbit/tome-crm` takes an optional `dataPolicy` on the intake submission input; with `'contact-only'` the `form-submission` activity writes no summary and `intakeTagFields` is not applied. **Check this when you upgrade:** `forms-submissions` gains an indexed `email` column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on `email`, so core's data export and account erasure include those rows from now on.
v0.4.0minor

ec7e7a8: Submissions are now staff-only by default. `submissionsReadAccess` defaults to `'admin-only'` on `intake-submissions` and `forms-submissions`; before, read and update were open to any signed-in session, which on a site with open signup means any account. **Check this when you upgrade:** if non-admin staff read submissions, pass `submissionsReadAccess: 'authenticated'`, on a site where every account is staff. `createIntakeLayer` now grants `intake:admin` to the `admin` role at startup (new option `grantRoles`, default `['admin']`, `false` to grant it yourself). Without that grant, a plain admin on a site whose `roles` is a relationship could not read submissions under `admin-only`. `withFormsAccess`'s `'submissions'` preset is now admin-only; `'submissions-authenticated'` keeps the old rules. `@wabbit/tome-intake` now needs `@wabbit/tome-core` 1.25.0 or later.

  • ec7e7a8: Submissions are now staff-only by default. `submissionsReadAccess` defaults to `'admin-only'` on `intake-submissions` and `forms-submissions`; before, read and update were open to any signed-in session, which on a site with open signup means any account. **Check this when you upgrade:** if non-admin staff read submissions, pass `submissionsReadAccess: 'authenticated'`, on a site where every account is staff. `createIntakeLayer` now grants `intake:admin` to the `admin` role at startup (new option `grantRoles`, default `['admin']`, `false` to grant it yourself). Without that grant, a plain admin on a site whose `roles` is a relationship could not read submissions under `admin-only`. `withFormsAccess`'s `'submissions'` preset is now admin-only; `'submissions-authenticated'` keeps the old rules. `@wabbit/tome-intake` now needs `@wabbit/tome-core` 1.25.0 or later.
v0.3.6patch

cf98722: Intake emails now send through your site's configured email adapter; before, they only sent with a Resend key. If your Payload config has an email adapter (nodemailer/SMTP, Resend, or any other), both the stakeholder copy and the submitter copy now go out through it, with no `RESEND_API_KEY` needed. When you set no `from`, the adapter's own default from-address is used; a `from` in the intake config or `EMAIL_FROM_ADDRESS` still wins. Sites with no adapter but a `RESEND_API_KEY` behave as before, and with neither, the email is logged and not sent. Only one send is ever attempted per email, so nothing is delivered twice.

  • cf98722: Intake emails now send through your site's configured email adapter; before, they only sent with a Resend key. If your Payload config has an email adapter (nodemailer/SMTP, Resend, or any other), both the stakeholder copy and the submitter copy now go out through it, with no `RESEND_API_KEY` needed. When you set no `from`, the adapter's own default from-address is used; a `from` in the intake config or `EMAIL_FROM_ADDRESS` still wins. Sites with no adapter but a `RESEND_API_KEY` behave as before, and with neither, the email is logged and not sent. Only one send is ever attempted per email, so nothing is delivered twice.
v0.3.5patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.4patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.3.3patch

3bba98a: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.

  • 3bba98a: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.
  • 7859c7e: `dispatchOrLog` now logs a failed email send through `payload.logger.error` before falling back to its logged-not-sent path. Previously the send error was discarded, so a misconfigured email adapter or a provider outage looked identical to a deliberate "logged, not sent". It still never throws.
  • 520bcbd: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.3.2patch

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.

  • 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.
  • ce3d12d: Put both packages' admin checks on core's primitive, and fix the `role`-singular defect in forms (2026-09-01 sale-readiness audit §5.2, §7, T3(g)). **Both of these WIDEN who counts as an admin. Neither narrows it — no session that passed before fails now.** **`@wabbit/tome-intake`.** `withIntakeAccess`'s `requireAdmin` matched a `roles` array against three literal strings and carried a TODO at the top of the file: "wire to tome-core capability registry when intake:admin capability is seeded". The TODO is discharged and deleted; the check is `sessionHasCapabilityOrLegacyAdmin(req, 'intake:admin')` from `@wabbit/tome-core/auth/repScoping`, the same primitive crm and deals use. **`@wabbit/tome-forms`.** `buildDraftScopedAccess` read `user.role` — **SINGULAR**. Every sibling layer, and core's own capability engine, read the `roles` ARRAY. That is a defect, not a style difference: on a site whose users carry `roles: ['admin']` — the shape core's legacy fallback and five other layers assume — this gate saw no admin at all, and every admin silently received the rep-scoped `{ user: { equals: self } }` Where clause instead of unrestricted access. It failed CLOSED, which is why nobody noticed: a form draft an admin could not see looked exactly like a draft that did not exist. It is now `sessionHasCapabilityOrLegacyAdmin(req, 'forms:admin')`. The primitive answers in order: a real capability grant via `canAsync` (through `_populatedRoles`, the enriched `role: string[]`, or a role fetch on `req.payload`; a populated `super-admin` role matches every capability by the engine's wildcard rule), then the legacy bootstrap fallback — a `roles` ARRAY containing `admin` / `superadmin` / `super-admin`. **Neither package declares its capability.** Grep for `defineCapabilities` finds it in core's defaults and in `@wabbit/tome-lms`, nowhere else — so `intake:admin` and `forms:admin` are not in any default vocabulary, and on a site that has not granted them itself the capability branch always misses and **the legacy role array is what actually makes admin access work**. That is not a defect; it is the bootstrap path, and it is why a site can install either layer and have a working admin gate on day one. Both file headers say so in as many words, so the next reader does not mistake the capability name for a wired-up feature. A site that DOES seed and grant the capability now gets it honored, which is what intake's TODO was asking for. The exact widening, per package: - **intake** — a role holding an `intake:admin` grant passes; a populated `super-admin` role passes; the enriched `role: string[]` shape is consulted where it was previously ignored. Unchanged: `create` is denied for EVERYONE including admins (every legitimate write goes through `submitIntakeAction` with `overrideAccess: true`, which is what makes the verify→persist pipeline unbypassable); the default preset still lets any authenticated session read and update; the legacy branch is still case-sensitive, still requires an array, still rejects near-misses. - **forms** — `roles: ['admin' | 'superadmin' | 'super-admin']` now passes (the defect fix); a `forms:admin` grant passes; a populated `super-admin` role passes. **No longer special:** `role: 'admin'` as a bare STRING, which the old code coerced into a one-element array and matched. No consumer in this repo writes that shape and core's engine does not read it, so such a session now falls through to the scoped Where clause. Unchanged: anonymous denied outright, non-admins scoped to their own drafts, `create` denied for everyone, `delete` admin-only, and the anonymous save/resume flow which goes through the server actions with `overrideAccess: true` + HMAC verification and never touched this gate. Both checks are asynchronous now, because the capability engine may resolve roles through `req.payload`; Payload access functions may return a promise, so this changes each module's internals, not its contract. Tests: `intake/tests/access.test.ts` was written by an earlier wave to pin the old heuristic "exactly as it behaves today, including the parts that are arguably wrong", explicitly so that "the capability wiring, when it lands, arrives as a deliberate diff against a stated baseline." This is that diff — the file is rewritten as the NEW truth table, with every moved row labeled and every unchanged row labeled. `forms/tests/draft-access.test.ts` is new and does the same job for the drafts preset. Both vitest configs gain a bare `@wabbit/tome-core` → source alias, which is load-bearing rather than cosmetic: the capability GRANT REGISTRY is module-scoped state, so a partial alias would split it in two and make the capability rows pass for the wrong reason.
  • 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.
  • 670d2a1: **`normalizeEmail` adoption on the email fields the 2026-09-01 audit flagged, plus the one swallowed error that had no logger.** Email is the cross-layer join key — forms hands a submission to intake, intake to CRM contact matching, CRM to marketing suppression — and `findContactByEmail` / `matchOrCreateContact` normalize before they query. Any layer that stores a raw address forks the same person into two records at the first hand-off. - **intake** — `intake-submissions.email` (required, indexed, the CRM join column) now normalizes through core's canonical `normalizeEmail` via a new `normalizeEmailField` field hook. Field-level rather than folded into `beforeValidateIntake`, which returns early on anything but a create: an admin retyping an address on update is exactly the case a create-only hook misses. - **forms** — the `emailRecipient` config field gains the same hook, and the email field-type descriptor's `sanitize` stops hand-rolling `v.trim().toLowerCase()` and delegates to `normalizeEmail`. That fork agreed byte-for-byte today, which is the problem: the day the shared helper learns anything, forms silently stops agreeing. (`assert:no-forked-primitives` catches exact-body forks, not inline expressions like this one.) - **crm** — `record-crm-activity-with-dedup.ts`'s account-resolution `catch` was the one swallowed error in the repo's sample with no logger call at all (audit §6). The swallow is correct — an activity row without an account link beats a dropped webhook — but a contacts lookup failing there is normally a slug misconfiguration or a permissions change, and every later activity lands unlinked until someone notices. It now warns through `req.payload.logger.warn` with a `console.warn` fallback, naming the contact and the slug, matching `access/presets.ts` and `hooks/stage-change-dispatch.ts`. Normalization stays deliverable-address preserving (trim + lowercase only; no dot-stripping, no plus-tag removal) — asserted, because the stored value is what gets emailed.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • 670d2a1: First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7). No behavior changed in marketing or intake; forms and crm ship one behavior change each, described below and covered by the same suites. - **marketing** — `webhooks/verify-utils` gets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check against `node:crypto` as an independent oracle, and a full contract table for `timingSafeEqualHex` (case folding, single-nibble mismatch, length short-circuit, and the fact that it does not validate hex — two empty strings compare equal, so callers must check presence first). Both adapters' verify functions are covered end to end: valid token accepts, one-character change rejects, missing/empty/differently-cased header rejects, malformed URL fails closed, and the unconfigured-secret pass-through is asserted explicitly rather than left implicit. The `secret` (Encharge) vs `webhookSecret` (Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secret `undefined`, which means bypass mode — a rename would open both endpoints silently. Also documents a real Web Crypto/node divergence: an empty secret THROWS rather than signing, which is the fail-closed outcome and is now pinned. - **intake** — `withIntakeAccess` gets the full truth table: `create` denied for everyone including admins (all writes go through `submitIntakeAction` with `overrideAccess: true`), read/update per preset, delete admin-only regardless of preset, plus wrapper semantics (overrides incoming access, shallow clone, defines exactly four keys). The file's `TODO: wire to tome-core capability registry` is untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignores `role`/`_populatedRoles`, so the wiring change arrives as a deliberate diff. - **forms** — `server/targets/webhook` covered for request shape (method, header merge and override, `payloadTransform`) and every failure path (non-2xx with detail, 200-char body truncation, body-read failure, network rejection, non-`Error` throw, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed: `fetch` is called with no `AbortSignal`, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands. - **crm** — `access/presets` covered for all three paths: seeded capability grants, the legacy `admin`/`superadmin`/`super-admin` roles-array fallback, and the bootstrap fallback that opens `crm:read` to any authenticated session. The last one is asserted in both directions — what it opens (read on every collection) and what it still refuses (write, delete) — because an un-seeded production site is running on it. `buildAccountDeleteGuard` and the once-per-process production warning are covered too. All four packages gain a `test` script (`vitest run`) and a vitest devDependency, and are removed from `scripts/assert-test-floor.mjs`'s ALLOWLIST — a stale allowlist entry fails the assert in both directions.
v0.3.1patch

Add a `submissionsReadAccess` option to `initForms`/`initIntake` (and their underlying collection factories). Set it to `'admin-only'` to restrict read (and update) of `forms-submissions` / `intake-submissions` to admins, so non-admin authenticated sessions — e.g. a public demo login — cannot read inbound submission PII via the data API. Defaults to `'authenticated'`, preserving the existing contract. Delete remains admin-only regardless. The feature code merged to main previously but was never released; this changeset ships it as the first published version to carry it.

  • Add a `submissionsReadAccess` option to `initForms`/`initIntake` (and their underlying collection factories). Set it to `'admin-only'` to restrict read (and update) of `forms-submissions` / `intake-submissions` to admins, so non-admin authenticated sessions — e.g. a public demo login — cannot read inbound submission PII via the data API. Defaults to `'authenticated'`, preserving the existing contract. Delete remains admin-only regardless. The feature code merged to main previously but was never released; this changeset ships it as the first published version to carry it.
v0.3.0minor

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: 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.
v0.1.8patch

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.
v0.1.7patch

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.
v0.1.6patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.1.5patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11

Marketing

v0.5.3
v0.5.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.5.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.5.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.5.0minor

947a42e: **BREAKING:** `TomeMarketingConfig` drops four keys nothing read; `providers`, `activitiesSlug` and `siteVisitActivityType` now take effect. The removed keys are `onSuppressionChange`, `onActivityCreated`, `onReplyReceived` and `aiAdapter`. **Migration:** remove those four keys from the object you pass to `createMarketingLayer`. Build the CRM hooks with `buildMarketingCrmConfig` and give them to the CRM layer, as before; the `TomeMarketingAiAdapter` type is still exported. - `activateCampaign` syncs through `config.providers` when no `adapters` are passed; explicit `adapters` still win. - `createMarketingWebhookHandler` and `createSiteVisitHandler` accept a `config` option; its slugs, `activitiesSlug` and `siteVisitActivityType` apply when the handler's own option is omitted. - `buildSuppressionMirror` is now typed against the CRM layer's `onSuppressionChange` directly; the function shape is unchanged.

  • 947a42e: **BREAKING:** `TomeMarketingConfig` drops four keys nothing read; `providers`, `activitiesSlug` and `siteVisitActivityType` now take effect. The removed keys are `onSuppressionChange`, `onActivityCreated`, `onReplyReceived` and `aiAdapter`. **Migration:** remove those four keys from the object you pass to `createMarketingLayer`. Build the CRM hooks with `buildMarketingCrmConfig` and give them to the CRM layer, as before; the `TomeMarketingAiAdapter` type is still exported. - `activateCampaign` syncs through `config.providers` when no `adapters` are passed; explicit `adapters` still win. - `createMarketingWebhookHandler` and `createSiteVisitHandler` accept a `config` option; its slugs, `activitiesSlug` and `siteVisitActivityType` apply when the handler's own option is omitted. - `buildSuppressionMirror` is now typed against the CRM layer's `onSuppressionChange` directly; the function shape is unchanged.
  • 9535dce: **BREAKING:** The Encharge and Kit adapters now reject every webhook when no webhook secret is configured, instead of accepting all of them. **Migration:** set `ENCHARGE_WEBHOOK_SECRET` / `KIT_WEBHOOK_SECRET` and pass it as `webhookSecret`. For local development before a secret exists, pass `allowUnverified: true` to `enchargeAdapter` / `kitAdapter` (or to `verifyEnchargeWebhook` / `verifyKitWebhook`); it is ignored when `NODE_ENV === 'production'`. Neither provider signs its webhooks, so the shared-secret check is the route's only authentication; a forgotten environment variable used to leave an open write endpoint. Rejections and allowed bypasses are both logged.
  • e7fdc8c: Campaign KPIs and segment-size recomputation now count rows with `payload.count()` instead of reading every matching membership or contact. No API or behavior change. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.4.2patch

8f41c63: **Encharge Ingest client: two assumptions verified against a live account, and blank values no longer overwrite a person's fields.** The client's identify payload was written from the Ingest API docs and carried `ASSUMPTION` flags because nobody had a key to check it with. a consumer's first ingest ran on 2026-09-19 with a real one, and the contact came back showing both a custom field and a tag applied by the same call. Two things are now observed rather than assumed, and the comments say so: - **Custom fields ride inside `user`.** A field sent as `sixdofSource` appeared on the person as "Sixdof Source". Sending custom fields in a sibling `properties` object — which `wabbit-site-core`'s local ingest helper does, and which was the reason that helper existed rather than importing this client — is not required for them to stick. - **Tags ride as a comma-separated string on `user`**, and the tag appeared on the contact. `removeTag` remains unverified and is now labeled as the only untested corner, since no consumer removes a tag yet. **Behavior change:** `identifyPerson` now drops fields whose value is `undefined`, `null` or `''` instead of sending them. Encharge writes whatever it receives, so an empty string is a destructive write, not a no-op — a caller assembling fields from optional sources (attribution with no campaign, a form with a skipped field) would silently erase data it never meant to touch. `wabbit-site-core`'s helper carried this guard for months; it belongs here, where every consumer gets it. Values that are meaningfully falsy (`0`, `false`) are kept. Adds `tests/encharge-client.test.ts` pinning the payload shapes the live check confirmed, the blank-stripping rule, and that `trackEvent`'s properties stay a sibling of `user` (an event's properties are the event's, not the person's). Consumer follow-up: `wabbit-site-core` can now retire `src/utilities/enchargeIngest.ts` and call `createEnchargeClient(...).identifyPerson(...)` directly — the swap its own header comment deferred on 2026-06-10 pending exactly this verification.

  • 8f41c63: **Encharge Ingest client: two assumptions verified against a live account, and blank values no longer overwrite a person's fields.** The client's identify payload was written from the Ingest API docs and carried `ASSUMPTION` flags because nobody had a key to check it with. a consumer's first ingest ran on 2026-09-19 with a real one, and the contact came back showing both a custom field and a tag applied by the same call. Two things are now observed rather than assumed, and the comments say so: - **Custom fields ride inside `user`.** A field sent as `sixdofSource` appeared on the person as "Sixdof Source". Sending custom fields in a sibling `properties` object — which `wabbit-site-core`'s local ingest helper does, and which was the reason that helper existed rather than importing this client — is not required for them to stick. - **Tags ride as a comma-separated string on `user`**, and the tag appeared on the contact. `removeTag` remains unverified and is now labeled as the only untested corner, since no consumer removes a tag yet. **Behavior change:** `identifyPerson` now drops fields whose value is `undefined`, `null` or `''` instead of sending them. Encharge writes whatever it receives, so an empty string is a destructive write, not a no-op — a caller assembling fields from optional sources (attribution with no campaign, a form with a skipped field) would silently erase data it never meant to touch. `wabbit-site-core`'s helper carried this guard for months; it belongs here, where every consumer gets it. Values that are meaningfully falsy (`0`, `false`) are kept. Adds `tests/encharge-client.test.ts` pinning the payload shapes the live check confirmed, the blank-stripping rule, and that `trackEvent`'s properties stay a sibling of `user` (an event's properties are the event's, not the person's). Consumer follow-up: `wabbit-site-core` can now retire `src/utilities/enchargeIngest.ts` and call `createEnchargeClient(...).identifyPerson(...)` directly — the swap its own header comment deferred on 2026-06-10 pending exactly this verification.
v0.4.1patch

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.

  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 670d2a1: First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7). No behavior changed in marketing or intake; forms and crm ship one behavior change each, described below and covered by the same suites. - **marketing** — `webhooks/verify-utils` gets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check against `node:crypto` as an independent oracle, and a full contract table for `timingSafeEqualHex` (case folding, single-nibble mismatch, length short-circuit, and the fact that it does not validate hex — two empty strings compare equal, so callers must check presence first). Both adapters' verify functions are covered end to end: valid token accepts, one-character change rejects, missing/empty/differently-cased header rejects, malformed URL fails closed, and the unconfigured-secret pass-through is asserted explicitly rather than left implicit. The `secret` (Encharge) vs `webhookSecret` (Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secret `undefined`, which means bypass mode — a rename would open both endpoints silently. Also documents a real Web Crypto/node divergence: an empty secret THROWS rather than signing, which is the fail-closed outcome and is now pinned. - **intake** — `withIntakeAccess` gets the full truth table: `create` denied for everyone including admins (all writes go through `submitIntakeAction` with `overrideAccess: true`), read/update per preset, delete admin-only regardless of preset, plus wrapper semantics (overrides incoming access, shallow clone, defines exactly four keys). The file's `TODO: wire to tome-core capability registry` is untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignores `role`/`_populatedRoles`, so the wiring change arrives as a deliberate diff. - **forms** — `server/targets/webhook` covered for request shape (method, header merge and override, `payloadTransform`) and every failure path (non-2xx with detail, 200-char body truncation, body-read failure, network rejection, non-`Error` throw, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed: `fetch` is called with no `AbortSignal`, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands. - **crm** — `access/presets` covered for all three paths: seeded capability grants, the legacy `admin`/`superadmin`/`super-admin` roles-array fallback, and the bootstrap fallback that opens `crm:read` to any authenticated session. The last one is asserted in both directions — what it opens (read on every collection) and what it still refuses (write, delete) — because an un-seeded production site is running on it. `buildAccountDeleteGuard` and the once-per-process production warning are covered too. All four packages gain a `test` script (`vitest run`) and a vitest devDependency, and are removed from `scripts/assert-test-floor.mjs`'s ALLOWLIST — a stale allowlist entry fails the assert in both directions.
  • 4aeedad: Delegates to `@wabbit/tome-workflow`; local guards deprecated. `@wabbit/tome-workflow` is the extracted canonical home for the status-transition table and the keyed side-effect registry — its own module headers say so, naming deals as the source it was ported from — and none of the three packages that still shipped a copy depended on it (2026-09-01 sale-readiness audit §5.1). All three now declare `@wabbit/tome-workflow` as a required, explicitly non-optional peer (`>=0.1.1 <1.0.0`) with a `workspace:*` devDependency twin, and all three raise their `@wabbit/tome-core` peer floor to `>=1.14.0 <2.0.0` (see the LayerFactoryConfig changeset — the new core subpaths do not exist below it). **deals — full delegation, five functions deprecated.** `defineDealSideEffect`, `replaceDealSideEffect`, `getDealSideEffect`, `getRegisteredSideEffectKeys` and `_resetSideEffectRegistry` are now thin wrappers over `defineWorkflowSideEffect` / `replaceWorkflowSideEffect` / `getWorkflowSideEffect` / `getRegisteredWorkflowSideEffectKeys` / `_resetWorkflowSideEffectRegistry`, each `@deprecated` with sunset at the next major. `findTransition` and `validateWorkflow` likewise wrap workflow's `findTransition` / `validateTransitionTable`. `resolveWorkflow` and `DEFAULT_DEAL_WORKFLOW` are NOT deprecated: the default quote lifecycle is deals' own domain data, and `resolveWorkflow` resolves an artifact type's optional workflow override, a deals concept with no workflow-layer equivalent. Two consequences of the deals delegation are invisible at a call site and are stated in the module headers. First, the side-effect store moves from a module-local `Map` to `globalThis` keyed by `Symbol.for` — a FIX, not a byproduct: deals ships separate ESM and CJS builds, so a handler registered through one instance was invisible through the other, and the transition then advanced with its side effect silently skipped. Workflow's own header names deals' local `Map` as the hazard it deliberately did not repeat. Second, the key namespace is now shared with every other workflow consumer, so a duplicate key across two layers throws at registration instead of quietly shadowing — the intended duplicate policy in both packages. One behavior change to note: `validateWorkflow`'s returned message prefix is now `[tome-workflow]` rather than `[tome-deals]`, because the validator is workflow's; that function shipped with zero call sites and zero tests. **marketing — lookup delegated, three semantics kept local.** The §9 campaign lifecycle is now published (module-scope, not from the barrel) as `MARKETING_CAMPAIGN_TRANSITION_TABLE`, derived from the existing adjacency map so the two cannot disagree, and the allow decision plus the "allowed from here" list come from workflow's `findTransition` / `allowedTransitionsFrom`. The adjacency map is kept as the source it is derived from because a flat table cannot distinguish a deliberately terminal status (`archived`, empty list) from a status absent from the map entirely (data corruption) — this hook has always reported those as two different errors, and collapsing them would turn "your database has an unknown status" into "that transition is not permitted". `buildStageTransitionGuard` is NOT deprecated: it is a Payload `beforeChange` hook factory and workflow ships no hook; `guardedTransition` is a server-side call that owns the write, and adopting it moves the transition out of the collection hook entirely. That is marketing's 1.0 question. **crm — lookup delegated, three semantics kept local, and this is the one that could not be forced.** `buildStageTransitionTable(stageConfig)` projects a `TomeCrmOpportunityStageConfig` onto a `WorkflowTransitionTable`, and the allow decision comes from workflow. Three semantics stay local, each because delegating them would change behavior: (1) a stage that declares NO `allowedTransitions` is UNCONSTRAINED in crm, and a transition table cannot distinguish "no edges declared" from "no edges permitted" — feeding those stages to `findTransition` would turn crm's open-by-default pipeline into a closed one for every consumer whose config declares transitions on some stages and not others; (2) an unknown stage key is a misconfiguration reported as one, ahead of any transition check; (3) the lost-category `lossCategory` requirement is a field-level data rule keyed off a stage's `category`, and hanging it on `WorkflowTransition.guard` would make every consumer of the exported table inherit a crm write-validation rule. The rejection message now also names the legal moves from the previous stage, sourced from `allowedTransitionsFrom` — strictly more diagnostic, same throw conditions. The deals↔CRM cascade re-entrancy handshake is untouched: `skipDealCascadeHooks` (deals `status-transition-guard.ts`) and `tomeCrmSuppressStageDispatch` (crm `advance-opportunity-stage.ts` → `stage-change-dispatch.ts`) behave exactly as the 2026-06-11 cascade-semantics amendment D2 documents. Neither guard's participation in that handshake changed; crm's stage guard never participated in it at all. New suites pinning the delegation, one per package (`tests/workflow-delegation.test.ts`, 12 + 9 + 8 assertions), each spying on the workflow module itself so a future edit that quietly restores a local copy fails a test rather than passing silently — which is exactly how the original fork survived four months of green CI.
v0.4.0minor

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: Security: both ESP webhook verifiers (Encharge, Kit) now use core's `timingSafeEqual`, replacing two independently authored and independently flawed local compares (Encharge short-circuited on length mismatch — a timing leak; Kit's dummy-loop mitigation never performed a real comparison on the mismatch path).
  • 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.
v0.2.3patch

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.
v0.2.2patch

ffe5bfe: Fix the main barrel transitively importing `'server-only'`, which broke `payload generate:types` for any consumer wiring `initMarketing` in payload.config (found by a consumer's adoption, first 0.2.x consumer). Two static chains reached `@wabbit/tome-crm/server`: `index.ts → lib/materialize-audience` and `collections → hooks/suppression-at-enrollment`. Both now dynamic-import at hook-execution time — hook bodies never run during config build, so the barrel stays clean; runtime behavior is unchanged. Verified by hot-swapping the rebuilt dist into that consumer's app: `payload generate:types` + `tsc --noEmit` green.

  • ffe5bfe: Fix the main barrel transitively importing `'server-only'`, which broke `payload generate:types` for any consumer wiring `initMarketing` in payload.config (found by a consumer's adoption, first 0.2.x consumer). Two static chains reached `@wabbit/tome-crm/server`: `index.ts → lib/materialize-audience` and `collections → hooks/suppression-at-enrollment`. Both now dynamic-import at hook-execution time — hook bodies never run during config build, so the barrel stays clean; runtime behavior is unchanged. Verified by hot-swapping the rebuilt dist into that consumer's app: `payload generate:types` + `tsc --noEmit` green.
v0.2.1patch

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.
v0.2.0minor

c5175e9: Provider clients join the public surface, and peers stop pinning exact versions: - **`createEnchargeClient` (+ option types) re-exported from `./adapters/encharge`** and **`KitClient` (+ option types) from `./adapters/kit`** — consumers doing direct server-side person upserts / subscribes import the client instead of hand-rolling the HTTP calls (wabbit-site-core's `enchargeIngest.ts` existed only because this export was missing). - **Peer ranges widened** — `@wabbit/tome-core` and `@wabbit/tome-crm` peers are now explicit semver ranges (`>=1.0.0 <2.0.0`, `>=0.2.0 <0.4.0`) instead of `workspace:*`, which published as exact-version pins and broke installs on every substrate bump. - Package removed from the changesets `ignore` list (stale pre-publish entry).

  • c5175e9: Provider clients join the public surface, and peers stop pinning exact versions: - **`createEnchargeClient` (+ option types) re-exported from `./adapters/encharge`** and **`KitClient` (+ option types) from `./adapters/kit`** — consumers doing direct server-side person upserts / subscribes import the client instead of hand-rolling the HTTP calls (wabbit-site-core's `enchargeIngest.ts` existed only because this export was missing). - **Peer ranges widened** — `@wabbit/tome-core` and `@wabbit/tome-crm` peers are now explicit semver ranges (`>=1.0.0 <2.0.0`, `>=0.2.0 <0.4.0`) instead of `workspace:*`, which published as exact-version pins and broke installs on every substrate bump. - Package removed from the changesets `ignore` list (stale pre-publish entry).
v0.1.0

Three collections: `marketing-campaigns`, `marketing-campaign-memberships` (carries per-prospect prototype-site linkage), `marketing-segments`.

  • Three collections: `marketing-campaigns`, `marketing-campaign-memberships` (carries per-prospect prototype-site linkage), `marketing-segments`.
  • `initMarketing` (registers via the tome-core layer registry) + `buildMarketingCrmConfig` (campaign-counter cascade via CRM `onActivityCreated` + suppression mirror via `onSuppressionChange`).
  • Server helpers: activate/pause/complete campaign, materialize audience, enroll/exclude contacts, KPIs, stalled memberships, expired prototype sites, recompute segment size.
  • Webhook ingest: `createMarketingWebhookHandler` + `createSiteVisitHandler`. Verification is **shared-secret** per provider — neither Encharge nor Kit signs webhooks.
  • Provider adapters (opt-in subpaths): **`/adapters/encharge`** (Ingest API; `X-Webhook-Token` header verification; consumer-mapped "Send Webhook" payload contract) and **`/adapters/kit`** (Kit v4; URL `?token=` verification; real Kit events — no email-open; email→id unsubscribe). Each ships a `README.md` with required consumer webhook config.

Forms

v0.12.2
v0.12.2patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.12.1patch

dedba9f: A one-step form no longer numbers its step, and a submit note that only repeats the contact-only data notice is printed once. In the stacked layout a single step has no "1" before its label. Under the contact-only policy the data notice above the submit button is always printed; an `appearance.submitNote` with exactly the same text is no longer printed a second time beside the button, and a different note still is.

  • dedba9f: A one-step form no longer numbers its step, and a submit note that only repeats the contact-only data notice is printed once. In the stacked layout a single step has no "1" before its label. Under the contact-only policy the data notice above the submit button is always printed; an `appearance.submitNote` with exactly the same text is no longer printed a second time beside the button, and a different note still is.
v0.12.0minor

4d06ec0: Forms and intake gain an opt-in `contact-only` data policy, per form or site-wide, plus retention policies and GDPR registration for submissions. `standard` forms behave as before. `dataPolicy: 'contact-only'` (on `defineForm`, `defineIntakeForm`, or `policy.default` on `createFormsLayer` and `createIntakeLayer`, with `policy.lock` and `policy.sensitiveTerms`) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms `email` target (use `intake` or `payload`), an unacknowledged custom target or router, and an intake email `htmlBuilder`. It is checked when the form is defined, when the layer is created, and when an admin form is saved. Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; `onIntake` receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook `data-form-data-notice`). The policy reduces what a form asks for and where the answers travel. It does not scan what people type, and it is not a legal or regulatory determination. New exports: `formsRetentionPolicies` and `intakeRetentionPolicies` (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. `@wabbit/tome-crm` takes an optional `dataPolicy` on the intake submission input; with `'contact-only'` the `form-submission` activity writes no summary and `intakeTagFields` is not applied. **Check this when you upgrade:** `forms-submissions` gains an indexed `email` column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on `email`, so core's data export and account erasure include those rows from now on.

  • 4d06ec0: Forms and intake gain an opt-in `contact-only` data policy, per form or site-wide, plus retention policies and GDPR registration for submissions. `standard` forms behave as before. `dataPolicy: 'contact-only'` (on `defineForm`, `defineIntakeForm`, or `policy.default` on `createFormsLayer` and `createIntakeLayer`, with `policy.lock` and `policy.sensitiveTerms`) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms `email` target (use `intake` or `payload`), an unacknowledged custom target or router, and an intake email `htmlBuilder`. It is checked when the form is defined, when the layer is created, and when an admin form is saved. Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; `onIntake` receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook `data-form-data-notice`). The policy reduces what a form asks for and where the answers travel. It does not scan what people type, and it is not a legal or regulatory determination. New exports: `formsRetentionPolicies` and `intakeRetentionPolicies` (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. `@wabbit/tome-crm` takes an optional `dataPolicy` on the intake submission input; with `'contact-only'` the `form-submission` activity writes no summary and `intakeTagFields` is not applied. **Check this when you upgrade:** `forms-submissions` gains an indexed `email` column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on `email`, so core's data export and account erasure include those rows from now on.
v0.11.0minor

ec7e7a8: Submissions are now staff-only by default. `submissionsReadAccess` defaults to `'admin-only'` on `intake-submissions` and `forms-submissions`; before, read and update were open to any signed-in session, which on a site with open signup means any account. **Check this when you upgrade:** if non-admin staff read submissions, pass `submissionsReadAccess: 'authenticated'`, on a site where every account is staff. `createIntakeLayer` now grants `intake:admin` to the `admin` role at startup (new option `grantRoles`, default `['admin']`, `false` to grant it yourself). Without that grant, a plain admin on a site whose `roles` is a relationship could not read submissions under `admin-only`. `withFormsAccess`'s `'submissions'` preset is now admin-only; `'submissions-authenticated'` keeps the old rules. `@wabbit/tome-intake` now needs `@wabbit/tome-core` 1.25.0 or later.

  • ec7e7a8: Submissions are now staff-only by default. `submissionsReadAccess` defaults to `'admin-only'` on `intake-submissions` and `forms-submissions`; before, read and update were open to any signed-in session, which on a site with open signup means any account. **Check this when you upgrade:** if non-admin staff read submissions, pass `submissionsReadAccess: 'authenticated'`, on a site where every account is staff. `createIntakeLayer` now grants `intake:admin` to the `admin` role at startup (new option `grantRoles`, default `['admin']`, `false` to grant it yourself). Without that grant, a plain admin on a site whose `roles` is a relationship could not read submissions under `admin-only`. `withFormsAccess`'s `'submissions'` preset is now admin-only; `'submissions-authenticated'` keeps the old rules. `@wabbit/tome-intake` now needs `@wabbit/tome-core` 1.25.0 or later.
v0.10.0minor

a7c30a1: A visitor without JavaScript who posts a form with a mistake now gets the form back with their answers kept, the fields their choices call for, and a message under each field to fix. Before, a single-step form posted without JavaScript came back with every answer gone and one joined error line, and a field that a rule shows (say, "How many teams?" after choosing Teams) never appeared, because the rules only ran on the starting values. Now: - On a failed post made without JavaScript, `submitFormAction`'s result also carries `fieldErrors` (one message per field) and `values` (the visitor's own answers). A submission target can return its own `fieldErrors`; they are passed through. - The re-rendered form lays those answers over its starting values and runs its rules on them, so the chosen type's fields render with their answers and messages. Each message is tied to its control by `aria-describedby`; the error summary lists them in field order with a link to each control, and the first invalid field gets `autofocus`. - Fixed: `multiselect` and `checkboxGroup` answers were refused on every submit, with or without JavaScript, because the server read only the first choice. They are now read in full, blanks and duplicates dropped, and checked against the field's options (an unknown value is rejected, as `select` rejects one); a required one with nothing chosen says "Required". Custom field types can opt in with the new descriptor flag `multiple: true`. - Never echoed: file inputs, author-declared `hidden` fields, the honeypot and other engine fields, unknown names, and any field the rules hid on the submitted answers (that value is dropped, as the server already did). Additive, no action needed: with JavaScript running, the client path's result and every render are byte-identical to before. The round trip covers single-step forms; multi-step forms still need JavaScript. New exported types: `TomeFormsSubmittedValue`, `TomeFormsSubmittedValues`. Also fixed, with script and without: - A blank number field no longer shows "Invalid input: expected number, received NaN". Blank is "not provided" (an optional `number`, `range` or `rating` passes; a required one says "Required" or the field's `validation.customMessage`), and text that is not a number says "Enter a number." (`rating`: "Enter a whole number.") or the author's `customMessage`. The `min` and `max` messages are plain too ("Enter a number of 1 or more."). Before, a required number field left blank or filled with letters on the server was read as 0 and passed. - With JavaScript running, a form that fails validation on Next or submit now shows the same error summary as the no-script round trip: one "Label: message" line per invalid field in field order, each linking to its control, in the `role="alert"` box, shown on every failure. Forms with no errors render exactly as before. Focus on error, with script: after a failed Next or submit, focus now goes to the first invalid field (the summary still renders, each entry linking to its field). New `TomeForm` prop `focusOnError: 'field' | 'summary'`, default `'field'`; `'summary'` keeps the earlier behavior of focusing the summary box. Without script the first invalid field is still autofocused. New opt-in `errorSummaryLabel` sets the summary heading, with a `{count}` token for the number of invalid fields (for example `"{count} things need fixing:"`); unset keeps "Please fix the following errors:". Field errors from a submission target and radio-group errors, with script on: - A submission target's own `fieldErrors` (a lead time, a seated cap) now land on the named fields on the client path, as they already did without script: each field gets `aria-invalid`, its message under it tied by `aria-describedby`, a line in the error summary in field order, and the first invalid field takes focus (`focusOnError`, default `'field'`; `'summary'` still focuses the box). Before, they appeared only as a joined line in the summary and focus went to the summary. A failure with no `fieldErrors` is unchanged. Keys that are not a field of the form are listed as plain text. - A `radio` or `checkboxGroup` with an error now carries `aria-invalid="true"` and `aria-describedby` (the error message, plus any help text) on its fieldset, and `aria-invalid` on each option, on both paths. The group's inner `role="group"` no longer repeats the description while an error shows. With no error the markup is byte-identical. The `tomeForm` block carries both of those options. Two new optional block fields, `errorSummaryLabel` (text) and `focusOnError` (select, `field` | `summary`), are forwarded by `TomeFormBlock` to `TomeForm`, so a site that renders forms through the block no longer has to render `TomeForm` directly to set the summary heading or where focus goes. Both are empty by default and an empty field changes nothing: markup is byte-identical. The block gains two Payload fields, so on Postgres generate a migration after upgrading. The summary heading in the singular: new optional `TomeForm` prop and block field `errorSummaryLabelOne` (for example `"1 thing needs fixing:"`) is used when exactly one field is invalid, so `"{count} things need fixing:"` no longer reads "1 things need fixing:". Unset (or empty) keeps `errorSummaryLabel` for one error too, so markup is byte-identical. The block gains one more Payload field, so on Postgres generate a migration after upgrading.

  • a7c30a1: A visitor without JavaScript who posts a form with a mistake now gets the form back with their answers kept, the fields their choices call for, and a message under each field to fix. Before, a single-step form posted without JavaScript came back with every answer gone and one joined error line, and a field that a rule shows (say, "How many teams?" after choosing Teams) never appeared, because the rules only ran on the starting values. Now: - On a failed post made without JavaScript, `submitFormAction`'s result also carries `fieldErrors` (one message per field) and `values` (the visitor's own answers). A submission target can return its own `fieldErrors`; they are passed through. - The re-rendered form lays those answers over its starting values and runs its rules on them, so the chosen type's fields render with their answers and messages. Each message is tied to its control by `aria-describedby`; the error summary lists them in field order with a link to each control, and the first invalid field gets `autofocus`. - Fixed: `multiselect` and `checkboxGroup` answers were refused on every submit, with or without JavaScript, because the server read only the first choice. They are now read in full, blanks and duplicates dropped, and checked against the field's options (an unknown value is rejected, as `select` rejects one); a required one with nothing chosen says "Required". Custom field types can opt in with the new descriptor flag `multiple: true`. - Never echoed: file inputs, author-declared `hidden` fields, the honeypot and other engine fields, unknown names, and any field the rules hid on the submitted answers (that value is dropped, as the server already did). Additive, no action needed: with JavaScript running, the client path's result and every render are byte-identical to before. The round trip covers single-step forms; multi-step forms still need JavaScript. New exported types: `TomeFormsSubmittedValue`, `TomeFormsSubmittedValues`. Also fixed, with script and without: - A blank number field no longer shows "Invalid input: expected number, received NaN". Blank is "not provided" (an optional `number`, `range` or `rating` passes; a required one says "Required" or the field's `validation.customMessage`), and text that is not a number says "Enter a number." (`rating`: "Enter a whole number.") or the author's `customMessage`. The `min` and `max` messages are plain too ("Enter a number of 1 or more."). Before, a required number field left blank or filled with letters on the server was read as 0 and passed. - With JavaScript running, a form that fails validation on Next or submit now shows the same error summary as the no-script round trip: one "Label: message" line per invalid field in field order, each linking to its control, in the `role="alert"` box, shown on every failure. Forms with no errors render exactly as before. Focus on error, with script: after a failed Next or submit, focus now goes to the first invalid field (the summary still renders, each entry linking to its field). New `TomeForm` prop `focusOnError: 'field' | 'summary'`, default `'field'`; `'summary'` keeps the earlier behavior of focusing the summary box. Without script the first invalid field is still autofocused. New opt-in `errorSummaryLabel` sets the summary heading, with a `{count}` token for the number of invalid fields (for example `"{count} things need fixing:"`); unset keeps "Please fix the following errors:". Field errors from a submission target and radio-group errors, with script on: - A submission target's own `fieldErrors` (a lead time, a seated cap) now land on the named fields on the client path, as they already did without script: each field gets `aria-invalid`, its message under it tied by `aria-describedby`, a line in the error summary in field order, and the first invalid field takes focus (`focusOnError`, default `'field'`; `'summary'` still focuses the box). Before, they appeared only as a joined line in the summary and focus went to the summary. A failure with no `fieldErrors` is unchanged. Keys that are not a field of the form are listed as plain text. - A `radio` or `checkboxGroup` with an error now carries `aria-invalid="true"` and `aria-describedby` (the error message, plus any help text) on its fieldset, and `aria-invalid` on each option, on both paths. The group's inner `role="group"` no longer repeats the description while an error shows. With no error the markup is byte-identical. The `tomeForm` block carries both of those options. Two new optional block fields, `errorSummaryLabel` (text) and `focusOnError` (select, `field` | `summary`), are forwarded by `TomeFormBlock` to `TomeForm`, so a site that renders forms through the block no longer has to render `TomeForm` directly to set the summary heading or where focus goes. Both are empty by default and an empty field changes nothing: markup is byte-identical. The block gains two Payload fields, so on Postgres generate a migration after upgrading. The summary heading in the singular: new optional `TomeForm` prop and block field `errorSummaryLabelOne` (for example `"1 thing needs fixing:"`) is used when exactly one field is invalid, so `"{count} things need fixing:"` no longer reads "1 things need fixing:". Unset (or empty) keeps `errorSummaryLabel` for one error too, so markup is byte-identical. The block gains one more Payload field, so on Postgres generate a migration after upgrading.
v0.9.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.9.1patch

6e128dd: Fix: a host-supplied `renderRichText` no longer crashes the render with "Functions cannot be passed directly to Client Components". `TomeForm` now renders the description, step intros and thank-you on the server and passes the client view the rendered nodes (`stepIntros`, `thankYou`) instead of the function. The `renderRichText` prop on `TomeForm` and `TomeFormBlock` is unchanged, and output without a `renderRichText` is byte-identical. `TomeFormClient` (normally rendered only by `TomeForm`) takes `stepIntros` and `thankYou` in place of its `renderRichText` prop.

  • 6e128dd: Fix: a host-supplied `renderRichText` no longer crashes the render with "Functions cannot be passed directly to Client Components". `TomeForm` now renders the description, step intros and thank-you on the server and passes the client view the rendered nodes (`stepIntros`, `thankYou`) instead of the function. The `renderRichText` prop on `TomeForm` and `TomeFormBlock` is unchanged, and output without a `renderRichText` is byte-identical. `TomeFormClient` (normally rendered only by `TomeForm`) takes `stepIntros` and `thankYou` in place of its `renderRichText` prop.
v0.9.0minor

dcbf05f: A one-step form can now hide its step legend visually while screen readers still read it: set `appearance.hideSingleStepLegend` on the form, or pass `hideSingleStepLegend` to `TomeForm`. The legend gets the sr-only class instead of being removed, so the fieldset keeps its accessible name. The admin `forms` Appearance group gains a "Hide Legend On Single-Step Forms" checkbox. Off by default, and forms with two or more steps ignore it, so existing renders are unchanged.

  • dcbf05f: A one-step form can now hide its step legend visually while screen readers still read it: set `appearance.hideSingleStepLegend` on the form, or pass `hideSingleStepLegend` to `TomeForm`. The legend gets the sr-only class instead of being removed, so the fieldset keeps its accessible name. The admin `forms` Appearance group gains a "Hide Legend On Single-Step Forms" checkbox. Off by default, and forms with two or more steps ignore it, so existing renders are unchanged.
  • 36836eb: A single-step form now submits with JavaScript turned off when you pass `TomeForm` a server action: it renders `<form action>` and posts to the same action, which validates and stores it as before. `submitFormAction` also accepts the `(previousState, formData)` call React uses for a posted form, follows the form's `redirectUrl` server-side after a successful post, and otherwise re-renders the page with the thank-you message or the validation error. The honeypot moves inside the form on this path, and `submitFormAction(form, payload, { requireJavaScript: true })` refuses a no-JavaScript post, for forms guarded by a script-only check such as Turnstile. Multi-step forms still need JavaScript; `progressiveSubmit={false}` keeps a single-step form JavaScript-only. A plain function passed as `action` renders exactly as before.
  • fc940e7: A filled honeypot field is now flagged on the JavaScript submit path too; before, the client never sent it, so only a no-JavaScript post could trip it. The client submit reads the `_hp` input from the form's section and sends it with the other values, so `_honeypotTriggered` is set exactly as on the no-JavaScript path. The honeypot stays where it was in the markup, so default renders are unchanged.
v0.8.1patch

4323db1: Checkboxes and radios in a form now show their box. `@wabbit/tome-ui`'s base stylesheet resets every input to `appearance: none` with no border or fill, which left a checkbox or radio an invisible box and made `accent-color` do nothing. The field styles restore the platform control (`appearance: auto`), so an unthemed form shows its choices. A theme that draws its own box sets `appearance: none` again at higher specificity.

  • 4323db1: Checkboxes and radios in a form now show their box. `@wabbit/tome-ui`'s base stylesheet resets every input to `appearance: none` with no border or fill, which left a checkbox or radio an invisible box and made `accent-color` do nothing. The field styles restore the platform control (`appearance: auto`), so an unthemed form shows its choices. A theme that draws its own box sets `appearance: none` again at higher specificity.
v0.8.0minor

4508783: `TomeForm` marks the row that holds its Back, Next and Submit buttons (and the submit note) `[data-form-actions]`, so a theme can lay the submit beside the fields without reaching it by `:has(button)`. Additive: the markup is otherwise unchanged.

  • 4508783: `TomeForm` marks the row that holds its Back, Next and Submit buttons (and the submit note) `[data-form-actions]`, so a theme can lay the submit beside the fields without reaching it by `:has(button)`. Additive: the markup is otherwise unchanged.
v0.7.0minor

eaf29f3: A form placement can now preselect an answer from a link or the URL, through a per-placement `acceptPrefill` allowlist. - **`acceptPrefill[]` and `prefillNote` on the `tomeForm` block** (and `TomeForm` props of the same names). Off until `acceptPrefill` lists field names; with none the markup is byte-for-byte what it was. Values come from `?field=value` on load and from clicks on in-page links carrying `data-form-prefill="field=value"` that point at the form's page or anchor. Only allowlisted fields are set, and a value must fit the control (a radio or select option, matched by value then label; text capped at 500 characters). A field a saved `localStorage` draft already answers is left alone on load. A click moves focus to the changed field (the radio itself when it is only visually hidden, else its label, else another control in the group) and scrolls to the form when the link has no fragment. With a fragment link the focus is kept after the browser's own hash navigation, which would otherwise drop it to `body`. - **Polite note.** The allowlist adds an empty `<p data-form-prefill-note aria-live="polite">` to the markup so the change is announced; `prefillNote` is its template (`{label}` is the chosen option's label). The field wrapper gains `data-form-prefilled`. New tokens `--tome-forms-prefill-note-size`, `--tome-forms-prefill-note-color` and `--tome-forms-prefill-note-gap`. - **Framework-free helper in core, re-exported here.** `applyFormPrefill(root, { allow, note, noteElement, search, skip, links })`, `parseFormPrefill`, `FORM_PREFILL_DEFAULT_NOTE` and `FORM_PREFILL_MAX_LENGTH` live in `@wabbit/tome-core/utilities/formPrefill`, so a form that does not use the forms engine (for example a Form Builder form) can reuse them without this package. `@wabbit/tome-forms/prefill` re-exports them, and `./blocks` re-exports them with `normalizeAcceptPrefill`. The `@wabbit/tome-core` peer floor is now `>=1.23.0`. - **Step hooks.** `data-form-step-count="<n>"` on the form root (the steps the visitor will see), `data-form-step-legend` on every step legend (a wizard's too; a stacked form already had it) and `data-form-fields` on a step's field container. They carry no styling here: a theme can, for example, hide the legend of a one-step form, which only repeats the form's own heading. Nothing else in the markup changes; a render with these three attributes removed is byte-for-byte the previous output (pinned by the golden files). **Migration:** the `tomeForm` block gains two optional Payload fields, so Postgres sites that register it should run `payload migrate:create` and `payload migrate` after upgrading. Nothing changes for a placement that leaves them empty.

  • eaf29f3: A form placement can now preselect an answer from a link or the URL, through a per-placement `acceptPrefill` allowlist. - **`acceptPrefill[]` and `prefillNote` on the `tomeForm` block** (and `TomeForm` props of the same names). Off until `acceptPrefill` lists field names; with none the markup is byte-for-byte what it was. Values come from `?field=value` on load and from clicks on in-page links carrying `data-form-prefill="field=value"` that point at the form's page or anchor. Only allowlisted fields are set, and a value must fit the control (a radio or select option, matched by value then label; text capped at 500 characters). A field a saved `localStorage` draft already answers is left alone on load. A click moves focus to the changed field (the radio itself when it is only visually hidden, else its label, else another control in the group) and scrolls to the form when the link has no fragment. With a fragment link the focus is kept after the browser's own hash navigation, which would otherwise drop it to `body`. - **Polite note.** The allowlist adds an empty `<p data-form-prefill-note aria-live="polite">` to the markup so the change is announced; `prefillNote` is its template (`{label}` is the chosen option's label). The field wrapper gains `data-form-prefilled`. New tokens `--tome-forms-prefill-note-size`, `--tome-forms-prefill-note-color` and `--tome-forms-prefill-note-gap`. - **Framework-free helper in core, re-exported here.** `applyFormPrefill(root, { allow, note, noteElement, search, skip, links })`, `parseFormPrefill`, `FORM_PREFILL_DEFAULT_NOTE` and `FORM_PREFILL_MAX_LENGTH` live in `@wabbit/tome-core/utilities/formPrefill`, so a form that does not use the forms engine (for example a Form Builder form) can reuse them without this package. `@wabbit/tome-forms/prefill` re-exports them, and `./blocks` re-exports them with `normalizeAcceptPrefill`. The `@wabbit/tome-core` peer floor is now `>=1.23.0`. - **Step hooks.** `data-form-step-count="<n>"` on the form root (the steps the visitor will see), `data-form-step-legend` on every step legend (a wizard's too; a stacked form already had it) and `data-form-fields` on a step's field container. They carry no styling here: a theme can, for example, hide the legend of a one-step form, which only repeats the form's own heading. Nothing else in the markup changes; a render with these three attributes removed is byte-for-byte the previous output (pinned by the golden files). **Migration:** the `tomeForm` block gains two optional Payload fields, so Postgres sites that register it should run `payload migrate:create` and `payload migrate` after upgrading. Nothing changes for a placement that leaves them empty.
v0.6.1patch

aa3e5f8: New token `--tome-forms-submit-justify` places the form's button row; the default (`flex-end`) is unchanged. A theme that sets the submit button first with the `submitNote` after it, at the start of the row, sets `--tome-forms-submit-justify: flex-start`. Before, only a structural selector on the row could move it.

  • aa3e5f8: New token `--tome-forms-submit-justify` places the form's button row; the default (`flex-end`) is unchanged. A theme that sets the submit button first with the `submitNote` after it, at the start of the row, sets `--tome-forms-submit-justify: flex-start`. Before, only a structural selector on the row could move it.
v0.6.0minor

6f067d6: Fixes found by the Counsel theme demo, plus an optional-field marker. - **Public pages with a `tomeForm` block no longer 500.** The `forms` collection's public read filtered on `_status`, a drafts field this collection doesn't have (its publish state is the `status` select), so every anonymous read failed, including the population of a `tomeForm` block's `form`. Anonymous reads now filter on `status = published`; signed-in reads are unchanged. A site that patched the read access itself can drop the patch. - **Required text-like fields refuse a blank value.** A blank required `text`/`textarea` beside an invalid field passed silently (zod skips an object's refinements once any field fails), and a whitespace-only value always passed. Required `text`, `textarea`, `email`, `tel`, `url`, `richtext`, `select`, `radio`, `date`, `datetime` and `time` fields now fail a blank value at the field with the field's `validation.customMessage`, or `Required`. A blank required email now says `Required` rather than `Invalid email address`. - **A select or radio uses its custom message.** `validation.customMessage` now replaces zod's `Invalid option: expected one of …`, which listed every raw option value to the visitor. Without a custom message the default stands. - **New `appearance.optionalMarker`.** Text after each optional field's label, such as `(optional)`, in `<span data-field-optional>`, styled muted through `--tome-forms-optional-color`, `--tome-forms-optional-weight` and `--tome-forms-optional-size`. Unset (the default) renders nothing, so existing forms are unchanged. The admin `forms` collection gains an **Optional Marker** text field in its Appearance group: on Postgres, generate a migration.

  • 6f067d6: Fixes found by the Counsel theme demo, plus an optional-field marker. - **Public pages with a `tomeForm` block no longer 500.** The `forms` collection's public read filtered on `_status`, a drafts field this collection doesn't have (its publish state is the `status` select), so every anonymous read failed, including the population of a `tomeForm` block's `form`. Anonymous reads now filter on `status = published`; signed-in reads are unchanged. A site that patched the read access itself can drop the patch. - **Required text-like fields refuse a blank value.** A blank required `text`/`textarea` beside an invalid field passed silently (zod skips an object's refinements once any field fails), and a whitespace-only value always passed. Required `text`, `textarea`, `email`, `tel`, `url`, `richtext`, `select`, `radio`, `date`, `datetime` and `time` fields now fail a blank value at the field with the field's `validation.customMessage`, or `Required`. A blank required email now says `Required` rather than `Invalid email address`. - **A select or radio uses its custom message.** `validation.customMessage` now replaces zod's `Invalid option: expected one of …`, which listed every raw option value to the visitor. Without a custom message the default stands. - **New `appearance.optionalMarker`.** Text after each optional field's label, such as `(optional)`, in `<span data-field-optional>`, styled muted through `--tome-forms-optional-color`, `--tome-forms-optional-weight` and `--tome-forms-optional-size`. Unset (the default) renders nothing, so existing forms are unchanged. The admin `forms` collection gains an **Optional Marker** text field in its Appearance group: on Postgres, generate a migration.
v0.5.0minor

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 behavior). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `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` honors `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 behavior). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `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` honors `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
v0.4.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.4.0minor

bb354ae: Forms now honor a post-submit `redirectUrl`: set it in `defineForm` or in the admin form's Redirect URL field, and a successful submit redirects there instead of showing the thank-you message. `redirectUrl` must be a same-origin path (`/thanks`) or an absolute `http(s)://` URL; `defineForm` and the admin field reject anything else. `loadAdminForm` now maps the admin field, which it previously dropped. A `redirectUrl` returned by the submission target itself still takes precedence, and forms without one behave as before. An admin form saved before this release with a Redirect URL that fails that rule now throws `TomeFormsConfigError` from `loadAdminForm`; fix the value in the admin and re-publish.

  • bb354ae: Forms now honor a post-submit `redirectUrl`: set it in `defineForm` or in the admin form's Redirect URL field, and a successful submit redirects there instead of showing the thank-you message. `redirectUrl` must be a same-origin path (`/thanks`) or an absolute `http(s)://` URL; `defineForm` and the admin field reject anything else. `loadAdminForm` now maps the admin field, which it previously dropped. A `redirectUrl` returned by the submission target itself still takes precedence, and forms without one behave as before. An admin form saved before this release with a Redirect URL that fails that rule now throws `TomeFormsConfigError` from `loadAdminForm`; fix the value in the admin and re-publish.
  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behavior change, and the published `dist/` is identical.
v0.3.8patch

02f05ab: Admin-authored forms can now be rendered, and saving one no longer blanks a code-defined form with the same slug. Admin-authored forms can now be rendered: `loadAdminForm` / `loadFormFromDocument` (from `@wabbit/tome-forms/server`) turn a published `forms` document into a validated `TomeFormsDefinition`, and `TomeForm`'s `form` prop now also accepts a definition, which it renders without a registry lookup. Saving an admin `forms` document no longer evicts a code-defined form with the same slug from the in-memory registry — code-seeded slugs take precedence. `TomeForm` renders the form's `description` through `renderRichText`; `description` and `thankYou` are now typed on `TomeFormsConfig`/`TomeFormsDefinition`, and a success state without `thankYou` content shows the default thank-you line even when a renderer is supplied. The no-op repeater-depth check in `defineForm` is removed: repeaters are opaque rows and cannot nest.

  • 02f05ab: Admin-authored forms can now be rendered, and saving one no longer blanks a code-defined form with the same slug. Admin-authored forms can now be rendered: `loadAdminForm` / `loadFormFromDocument` (from `@wabbit/tome-forms/server`) turn a published `forms` document into a validated `TomeFormsDefinition`, and `TomeForm`'s `form` prop now also accepts a definition, which it renders without a registry lookup. Saving an admin `forms` document no longer evicts a code-defined form with the same slug from the in-memory registry — code-seeded slugs take precedence. `TomeForm` renders the form's `description` through `renderRichText`; `description` and `thankYou` are now typed on `TomeFormsConfig`/`TomeFormsDefinition`, and a success state without `thankYou` content shows the default thank-you line even when a renderer is supplied. The no-op repeater-depth check in `defineForm` is removed: repeaters are opaque rows and cannot nest.
  • 0aa80a3: Drops the unused `@wabbit/tome-ui` peer dependency; nothing in the package imported it.
  • b47f122: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.
  • 520bcbd: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.3.7patch

d5303ec: Fix optional fields (tel, email, url, number, select, date, datetime, time) rejecting blank input. A field declared `required: false` still failed validation when left empty — e.g. an optional "Phone" field showed "Invalid phone number" and blocked submission even when untouched, because `compileFormSchema` wrapped non-required fields with `.optional()`, which accepts `undefined` but not the empty string `""` an untouched HTML input actually submits. Non-required, non-hidden fields now normalize `""`, whitespace-only strings, and `null` to `undefined` before the field-type rule runs, so leaving an optional field blank passes and a genuinely invalid value (e.g. a malformed phone number) still fails. Applies identically server-side (`submitFormAction`) and client-side (`TomeForm.client.tsx`'s inline resolver), since both call `compileFormSchema`. Stored submission data for a skipped optional field is now `undefined`/omitted rather than a persisted empty string.

  • d5303ec: Fix optional fields (tel, email, url, number, select, date, datetime, time) rejecting blank input. A field declared `required: false` still failed validation when left empty — e.g. an optional "Phone" field showed "Invalid phone number" and blocked submission even when untouched, because `compileFormSchema` wrapped non-required fields with `.optional()`, which accepts `undefined` but not the empty string `""` an untouched HTML input actually submits. Non-required, non-hidden fields now normalize `""`, whitespace-only strings, and `null` to `undefined` before the field-type rule runs, so leaving an optional field blank passes and a genuinely invalid value (e.g. a malformed phone number) still fails. Applies identically server-side (`submitFormAction`) and client-side (`TomeForm.client.tsx`'s inline resolver), since both call `compileFormSchema`. Stored submission data for a skipped optional field is now `undefined`/omitted rather than a persisted empty string.
v0.3.6patch

ddbde22: TomeForm's focus/announce effect now tracks the step index directly instead of a first-render flag, since the prior guard still re-fired on the render right after mount and stole focus/scroll on load.

  • ddbde22: TomeForm's focus/announce effect now tracks the step index directly instead of a first-render flag, since the prior guard still re-fired on the render right after mount and stole focus/scroll on load.
v0.3.5patch

0d5b2e3: TomeForm no longer steals focus and scrolls the page on first render — only on an actual step change.

  • 0d5b2e3: TomeForm no longer steals focus and scrolls the page on first render — only on an actual step change.
v0.3.4patch

f54c4c3: Fix the multi-step form's fields container collapsing to zero width by clearing the full-width `.stepLegend` float on `.stepIntro` and `.fieldsContainer`.

  • f54c4c3: Fix the multi-step form's fields container collapsing to zero width by clearing the full-width `.stepLegend` float on `.stepIntro` and `.fieldsContainer`.
v0.3.3patch

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.

  • 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.
  • 73081e6: Outbound webhooks now time out. `dispatchToWebhook` called `fetch` with no `AbortSignal`, so a submission target that accepted the connection and never answered held the submitter's own request open for as long as the runtime's fetch default allowed. The URL is operator-configured but third-party: this was a denial of service available to whoever the operator pasted into a form config, and the audit (§7) had already pinned the absence in a test as a standing ticket. **New default behavior:** every request carries `AbortSignal.timeout(10_000)`. `TomeFormsSubmissionTargetWebhook` gains an optional `timeoutMs` to raise or lower it per target, and `DEFAULT_WEBHOOK_TIMEOUT_MS` is exported so a consumer can read the default rather than restate it. Ten seconds is long enough for a cold serverless receiver to wake up and short enough that a hung endpoint is not the submitter's problem. A timeout surfaces through the same never-throws shape as every other delivery failure — `{ ok: false, error: 'Webhook did not respond within 10000ms' }`. The raw `TimeoutError`/`AbortError` message is replaced because it is runtime-dependent and says nothing about which webhook failed; the duration is the part an operator can act on. Nothing about the success path, the non-2xx path, the 200-character body truncation, or the deliberate pass-through of a throwing `payloadTransform` changed. The pinning test flipped with it: `webhook-target.test.ts` asserted `init.signal` was **undefined**; it now asserts the signal is present and unaborted, that the default is 10s, that `timeoutMs` overrides it, and that both `TimeoutError` and `AbortError` map to the duration-naming failure result. 18 tests pass. A future change that drops the signal fails here.
  • ce3d12d: Put both packages' admin checks on core's primitive, and fix the `role`-singular defect in forms (2026-09-01 sale-readiness audit §5.2, §7, T3(g)). **Both of these WIDEN who counts as an admin. Neither narrows it — no session that passed before fails now.** **`@wabbit/tome-intake`.** `withIntakeAccess`'s `requireAdmin` matched a `roles` array against three literal strings and carried a TODO at the top of the file: "wire to tome-core capability registry when intake:admin capability is seeded". The TODO is discharged and deleted; the check is `sessionHasCapabilityOrLegacyAdmin(req, 'intake:admin')` from `@wabbit/tome-core/auth/repScoping`, the same primitive crm and deals use. **`@wabbit/tome-forms`.** `buildDraftScopedAccess` read `user.role` — **SINGULAR**. Every sibling layer, and core's own capability engine, read the `roles` ARRAY. That is a defect, not a style difference: on a site whose users carry `roles: ['admin']` — the shape core's legacy fallback and five other layers assume — this gate saw no admin at all, and every admin silently received the rep-scoped `{ user: { equals: self } }` Where clause instead of unrestricted access. It failed CLOSED, which is why nobody noticed: a form draft an admin could not see looked exactly like a draft that did not exist. It is now `sessionHasCapabilityOrLegacyAdmin(req, 'forms:admin')`. The primitive answers in order: a real capability grant via `canAsync` (through `_populatedRoles`, the enriched `role: string[]`, or a role fetch on `req.payload`; a populated `super-admin` role matches every capability by the engine's wildcard rule), then the legacy bootstrap fallback — a `roles` ARRAY containing `admin` / `superadmin` / `super-admin`. **Neither package declares its capability.** Grep for `defineCapabilities` finds it in core's defaults and in `@wabbit/tome-lms`, nowhere else — so `intake:admin` and `forms:admin` are not in any default vocabulary, and on a site that has not granted them itself the capability branch always misses and **the legacy role array is what actually makes admin access work**. That is not a defect; it is the bootstrap path, and it is why a site can install either layer and have a working admin gate on day one. Both file headers say so in as many words, so the next reader does not mistake the capability name for a wired-up feature. A site that DOES seed and grant the capability now gets it honored, which is what intake's TODO was asking for. The exact widening, per package: - **intake** — a role holding an `intake:admin` grant passes; a populated `super-admin` role passes; the enriched `role: string[]` shape is consulted where it was previously ignored. Unchanged: `create` is denied for EVERYONE including admins (every legitimate write goes through `submitIntakeAction` with `overrideAccess: true`, which is what makes the verify→persist pipeline unbypassable); the default preset still lets any authenticated session read and update; the legacy branch is still case-sensitive, still requires an array, still rejects near-misses. - **forms** — `roles: ['admin' | 'superadmin' | 'super-admin']` now passes (the defect fix); a `forms:admin` grant passes; a populated `super-admin` role passes. **No longer special:** `role: 'admin'` as a bare STRING, which the old code coerced into a one-element array and matched. No consumer in this repo writes that shape and core's engine does not read it, so such a session now falls through to the scoped Where clause. Unchanged: anonymous denied outright, non-admins scoped to their own drafts, `create` denied for everyone, `delete` admin-only, and the anonymous save/resume flow which goes through the server actions with `overrideAccess: true` + HMAC verification and never touched this gate. Both checks are asynchronous now, because the capability engine may resolve roles through `req.payload`; Payload access functions may return a promise, so this changes each module's internals, not its contract. Tests: `intake/tests/access.test.ts` was written by an earlier wave to pin the old heuristic "exactly as it behaves today, including the parts that are arguably wrong", explicitly so that "the capability wiring, when it lands, arrives as a deliberate diff against a stated baseline." This is that diff — the file is rewritten as the NEW truth table, with every moved row labeled and every unchanged row labeled. `forms/tests/draft-access.test.ts` is new and does the same job for the drafts preset. Both vitest configs gain a bare `@wabbit/tome-core` → source alias, which is load-bearing rather than cosmetic: the capability GRANT REGISTRY is module-scoped state, so a partial alias would split it in two and make the capability rows pass for the wrong reason.
  • 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.
  • 670d2a1: **`normalizeEmail` adoption on the email fields the 2026-09-01 audit flagged, plus the one swallowed error that had no logger.** Email is the cross-layer join key — forms hands a submission to intake, intake to CRM contact matching, CRM to marketing suppression — and `findContactByEmail` / `matchOrCreateContact` normalize before they query. Any layer that stores a raw address forks the same person into two records at the first hand-off. - **intake** — `intake-submissions.email` (required, indexed, the CRM join column) now normalizes through core's canonical `normalizeEmail` via a new `normalizeEmailField` field hook. Field-level rather than folded into `beforeValidateIntake`, which returns early on anything but a create: an admin retyping an address on update is exactly the case a create-only hook misses. - **forms** — the `emailRecipient` config field gains the same hook, and the email field-type descriptor's `sanitize` stops hand-rolling `v.trim().toLowerCase()` and delegates to `normalizeEmail`. That fork agreed byte-for-byte today, which is the problem: the day the shared helper learns anything, forms silently stops agreeing. (`assert:no-forked-primitives` catches exact-body forks, not inline expressions like this one.) - **crm** — `record-crm-activity-with-dedup.ts`'s account-resolution `catch` was the one swallowed error in the repo's sample with no logger call at all (audit §6). The swallow is correct — an activity row without an account link beats a dropped webhook — but a contacts lookup failing there is normally a slug misconfiguration or a permissions change, and every later activity lands unlinked until someone notices. It now warns through `req.payload.logger.warn` with a `console.warn` fallback, naming the contact and the slug, matching `access/presets.ts` and `hooks/stage-change-dispatch.ts`. Normalization stays deliverable-address preserving (trim + lowercase only; no dot-stripping, no plus-tag removal) — asserted, because the stored value is what gets emailed.
  • 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source already carries.
  • 670d2a1: First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7). No behavior changed in marketing or intake; forms and crm ship one behavior change each, described below and covered by the same suites. - **marketing** — `webhooks/verify-utils` gets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check against `node:crypto` as an independent oracle, and a full contract table for `timingSafeEqualHex` (case folding, single-nibble mismatch, length short-circuit, and the fact that it does not validate hex — two empty strings compare equal, so callers must check presence first). Both adapters' verify functions are covered end to end: valid token accepts, one-character change rejects, missing/empty/differently-cased header rejects, malformed URL fails closed, and the unconfigured-secret pass-through is asserted explicitly rather than left implicit. The `secret` (Encharge) vs `webhookSecret` (Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secret `undefined`, which means bypass mode — a rename would open both endpoints silently. Also documents a real Web Crypto/node divergence: an empty secret THROWS rather than signing, which is the fail-closed outcome and is now pinned. - **intake** — `withIntakeAccess` gets the full truth table: `create` denied for everyone including admins (all writes go through `submitIntakeAction` with `overrideAccess: true`), read/update per preset, delete admin-only regardless of preset, plus wrapper semantics (overrides incoming access, shallow clone, defines exactly four keys). The file's `TODO: wire to tome-core capability registry` is untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignores `role`/`_populatedRoles`, so the wiring change arrives as a deliberate diff. - **forms** — `server/targets/webhook` covered for request shape (method, header merge and override, `payloadTransform`) and every failure path (non-2xx with detail, 200-char body truncation, body-read failure, network rejection, non-`Error` throw, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed: `fetch` is called with no `AbortSignal`, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands. - **crm** — `access/presets` covered for all three paths: seeded capability grants, the legacy `admin`/`superadmin`/`super-admin` roles-array fallback, and the bootstrap fallback that opens `crm:read` to any authenticated session. The last one is asserted in both directions — what it opens (read on every collection) and what it still refuses (write, delete) — because an un-seeded production site is running on it. `buildAccountDeleteGuard` and the once-per-process production warning are covered too. All four packages gain a `test` script (`vitest run`) and a vitest devDependency, and are removed from `scripts/assert-test-floor.mjs`'s ALLOWLIST — a stale allowlist entry fails the assert in both directions.
v0.3.2patch

Add a `submissionsReadAccess` option to `initForms`/`initIntake` (and their underlying collection factories). Set it to `'admin-only'` to restrict read (and update) of `forms-submissions` / `intake-submissions` to admins, so non-admin authenticated sessions — e.g. a public demo login — cannot read inbound submission PII via the data API. Defaults to `'authenticated'`, preserving the existing contract. Delete remains admin-only regardless. The feature code merged to main previously but was never released; this changeset ships it as the first published version to carry it.

  • Add a `submissionsReadAccess` option to `initForms`/`initIntake` (and their underlying collection factories). Set it to `'admin-only'` to restrict read (and update) of `forms-submissions` / `intake-submissions` to admins, so non-admin authenticated sessions — e.g. a public demo login — cannot read inbound submission PII via the data API. Defaults to `'authenticated'`, preserving the existing contract. Delete remains admin-only regardless. The feature code merged to main previously but was never released; this changeset ships it as the first published version to carry it.
v0.3.1patch

3109a55: forms-fields collection: explicit GraphQL type names (`<PascalSlug>Def`/`<PascalSlug>Defs`). Payload's policies builder emits a `${TypeName}Fields` type for every collection, so any `<forms>`+`<forms>-fields` slug pair collided ("Schema must contain uniquely named types but contains multiple types named 'TomeFormsFields'") and broke the consumer's entire GraphQL endpoint at boot. The default `forms`/`forms-fields` slugs had the same inherent collision.

  • 3109a55: forms-fields collection: explicit GraphQL type names (`<PascalSlug>Def`/`<PascalSlug>Defs`). Payload's policies builder emits a `${TypeName}Fields` type for every collection, so any `<forms>`+`<forms>-fields` slug pair collided ("Schema must contain uniquely named types but contains multiple types named 'TomeFormsFields'") and broke the consumer's entire GraphQL endpoint at boot. The default `forms`/`forms-fields` slugs had the same inherent collision.
v0.3.0minor

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.
  • 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.
  • aef2725: Monolith decompositions (behavior- and markup-preserving; public APIs unchanged; markup identity mechanically verified per file): forms' FieldRenderer 633→84 via a field-control registry + shared FieldChrome (consent/checkbox byte-identical branches merged) and TomeForm 656→451 via four extracted hooks (the ordering-critical resolver sync deliberately stays inline, documented); rpg's CharacterSheet 841→130 across panels + three editing hooks + persistence hook (the StrictMode XP-ledger charRef guard preserved verbatim); gallery's GalleryIndex 1032→431 (BlockThumb/BlockCard/Toolbar/useFilteredCatalog siblings, T2's debounce+memo preserved); webgl's WebglCanvasProvider 938→546 (useTransitionOrchestrator + useCanvasRenderer extracted; settle thresholds hoisted to named consts); admin's mergeAdminComponents 828→404 orchestrator + four helpers (all docblocks relocated, 717 tests unmodified) and Nav's config-reading now typed (6 of 8 `as any` casts eliminated); marketing-starter's PricingPlans extracts its GSAP toggle timeline hook + a memoized card. rpg additionally trusts the denormalized `xpTotal` on sheet load/save hot paths (full recompute stays at the XP-recording reconciliation point).
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-core@1.4.0 - @wabbit/tome-ui@0.9.9
v0.1.8patch

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.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-core@1.2.1 - @wabbit/tome-ui@0.9.7
v0.1.3patch

Drop the inner `'use server'` directive from the action returned by `submitFormAction`. Under Next's server-action transform a directive here double-registers the action — `"Cannot redefine property: $$id"` when the consumer re-exports it, or a call-stack overflow when it is wrapped. The single registration point is the consumer's own `'use server'` module, which exports `submitFormAction(...)`'s result directly. Plain async fn also keeps the tsx/test path working. Surfaced + proven by tome-starter under registry consumption: a browser submit at `/forms-demo` now writes a row to `forms-submissions` end-to-end.

  • Drop the inner `'use server'` directive from the action returned by `submitFormAction`. Under Next's server-action transform a directive here double-registers the action — `"Cannot redefine property: $$id"` when the consumer re-exports it, or a call-stack overflow when it is wrapped. The single registration point is the consumer's own `'use server'` module, which exports `submitFormAction(...)`'s result directly. Plain async fn also keeps the tsx/test path working. Surfaced + proven by tome-starter under registry consumption: a browser submit at `/forms-demo` now writes a row to `forms-submissions` end-to-end.
v0.1.2patch

Re-export the React-free `tomeFormBlock` / `createTomeFormBlock` block config from the package **root** (`.`). Registry consumers can now register the `tomeForm` block in a Payload config / Pages layout without importing the `./blocks` barrel (which pulls the React render components + CSS modules and breaks `payload generate:types`). The component side stays in `./blocks`. Surfaced by tome-starter moving to registry consumption — `@wabbit/tome-forms/blocks/tomeFormBlock` is not an exported subpath, so the React-free config needs a first-class export.

  • Re-export the React-free `tomeFormBlock` / `createTomeFormBlock` block config from the package **root** (`.`). Registry consumers can now register the `tomeForm` block in a Payload config / Pages layout without importing the `./blocks` barrel (which pulls the React render components + CSS modules and breaks `payload generate:types`). The component side stays in `./blocks`. Surfaced by tome-starter moving to registry consumption — `@wabbit/tome-forms/blocks/tomeFormBlock` is not an exported subpath, so the React-free config needs a first-class export.
v0.1.1patch

Fix three fat-slug type defects the package's own scoped typecheck cannot catch (in isolation Payload's `CollectionSlug` is `string`; in a consumer with generated `payload-types.ts` it narrows to a union). Surfaced by the first real consumer (tome-starter): - `blocks/tomeFormBlock.ts` — `relationTo: formsSlug as CollectionSlug`. - `hooks/beforeChange.ts` — forms-drafts upsert read cast through `as unknown as`. - `server/targets/payload.ts` — dynamic-slug `payload.create` `collection`/`data` cast `as never`, result narrowed to `{ id }`. Type-only; no runtime change. Scoped `tsc` 0 + `smoke` 19/19 re-verified; tome-starter typechecks the forms source clean.

  • Fix three fat-slug type defects the package's own scoped typecheck cannot catch (in isolation Payload's `CollectionSlug` is `string`; in a consumer with generated `payload-types.ts` it narrows to a union). Surfaced by the first real consumer (tome-starter): - `blocks/tomeFormBlock.ts` — `relationTo: formsSlug as CollectionSlug`. - `hooks/beforeChange.ts` — forms-drafts upsert read cast through `as unknown as`. - `server/targets/payload.ts` — dynamic-slug `payload.create` `collection`/`data` cast `as never`, result narrowed to `{ id }`. Type-only; no runtime change. Scoped `tsc` 0 + `smoke` 19/19 re-verified; tome-starter typechecks the forms source clean.
v0.1.0minor

8c908d9: Initial release of `@wabbit/tome-forms` — the native foundational forms layer. A first-class layer (not a block pack, not a `@payloadcms/plugin-form-builder` wrapper): four collections (`forms`, `forms-fields`, `forms-submissions`, `forms-drafts`; `forms-` prefix reserved, all slugs overridable), a JSON-serializable conditional-logic AST evaluator, isomorphic zod validation, multi-step runtime, a field-type registry, the `tomeForm` block render surface, WCAG 2.2 AA, and theme-reactive styling. Five subpaths: `.`, `./blocks`, `./server`, `./collections`, `./test`. Sits beneath `@wabbit/tome-intake` in the acyclic chain `sites → forms → intake → onIntake → crm/marketing/lms`; forms imports none of them. Consumed downstream via intake's `onIntake` hook. Retires `YouFormBlock` + agency-essentials `FormBlock` + the `@payloadcms/plugin-form-builder` render dependency (migration tracked; a REQUIRED `@wabbit/tome-intake` spec amendment is flagged, to apply when that spec is next opened). Validated 2026-05-18: review + runtime smoke caught and root-fixed four runtime defects (D1 release-blocking zod-v4 `formatZodError` crash; D2 static-required emptiness; D3 guarded-derivation value clobber; D4 `defineForm` not fail-loud), plus a zod-v4 `ZodRawShape`-readonly compile fix in `zodCompiler`. Durable gate: `pnpm --filter ./packages/forms smoke` (19/19). Package is zod-v4-only (`zod` peer `^4.0.0`).

  • 8c908d9: Initial release of `@wabbit/tome-forms` — the native foundational forms layer. A first-class layer (not a block pack, not a `@payloadcms/plugin-form-builder` wrapper): four collections (`forms`, `forms-fields`, `forms-submissions`, `forms-drafts`; `forms-` prefix reserved, all slugs overridable), a JSON-serializable conditional-logic AST evaluator, isomorphic zod validation, multi-step runtime, a field-type registry, the `tomeForm` block render surface, WCAG 2.2 AA, and theme-reactive styling. Five subpaths: `.`, `./blocks`, `./server`, `./collections`, `./test`. Sits beneath `@wabbit/tome-intake` in the acyclic chain `sites → forms → intake → onIntake → crm/marketing/lms`; forms imports none of them. Consumed downstream via intake's `onIntake` hook. Retires `YouFormBlock` + agency-essentials `FormBlock` + the `@payloadcms/plugin-form-builder` render dependency (migration tracked; a REQUIRED `@wabbit/tome-intake` spec amendment is flagged, to apply when that spec is next opened). Validated 2026-05-18: review + runtime smoke caught and root-fixed four runtime defects (D1 release-blocking zod-v4 `formatZodError` crash; D2 static-required emptiness; D3 guarded-derivation value clobber; D4 `defineForm` not fail-loud), plus a zod-v4 `ZodRawShape`-readonly compile fix in `zodCompiler`. Durable gate: `pnpm --filter ./packages/forms smoke` (19/19). Package is zod-v4-only (`zod` peer `^4.0.0`).

Directory

v2.0.0
v2.0.0major

204ce92: **BREAKING:** these packages now require `@wabbit/tome-economy` 0.18.0 or later, because they use its renamed `SubscriptionCanceledEvent`, `OrderCanceledEvent` and `subscription.canceled` names. Update `@wabbit/tome-economy` in the same change as these packages.

  • 204ce92: **BREAKING:** these packages now require `@wabbit/tome-economy` 0.18.0 or later, because they use its renamed `SubscriptionCanceledEvent`, `OrderCanceledEvent` and `subscription.canceled` names. Update `@wabbit/tome-economy` in the same change as these packages.
  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v1.10.0minor

1619d8c: **BREAKING:** `@wabbit/tome-directory/maps` now extends the neutral types in `@wabbit/tome-maps`, a new required peer. **Migration:** install `@wabbit/tome-maps` alongside `@wabbit/tome-directory`. The peer is type-only, so there is no runtime change; every `Directory*` map type keeps its name and shape, and `DirectoryMapPin` is now the neutral `MapPin` plus `tier`, `sponsored` and `openState`.

  • 1619d8c: **BREAKING:** `@wabbit/tome-directory/maps` now extends the neutral types in `@wabbit/tome-maps`, a new required peer. **Migration:** install `@wabbit/tome-maps` alongside `@wabbit/tome-directory`. The peer is type-only, so there is no runtime change; every `Directory*` map type keeps its name and shape, and `DirectoryMapPin` is now the neutral `MapPin` plus `tier`, `sponsored` and `openState`.
v1.9.2patch

1effa1c: A listing type with no extra fields no longer adds an empty details group, which broke the GraphQL API.

  • 1effa1c: A listing type with no extra fields no longer adds an empty details group, which broke the GraphQL API.
v1.9.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v1.9.0minor

6216174: New payload-free `@wabbit/tome-directory/listing-types` subpath for client-reachable code that only needs the listing-type registry. It exports `defineListingType`, `listingTypeRegistry`, `registerListingType`, `getListingType`, `hasListingType`, `getAllListingTypes`, `resetListingTypeRegistry` and the `DirectoryListingType` type, and shares one store with the package root, which still exports them all. `DirectoryListingLink` gains an optional `id` (the Payload array-row id) for renderers to key on.

  • 6216174: New payload-free `@wabbit/tome-directory/listing-types` subpath for client-reachable code that only needs the listing-type registry. It exports `defineListingType`, `listingTypeRegistry`, `registerListingType`, `getListingType`, `hasListingType`, `getAllListingTypes`, `resetListingTypeRegistry` and the `DirectoryListingType` type, and shares one store with the package root, which still exports them all. `DirectoryListingLink` gains an optional `id` (the Payload array-row id) for renderers to key on.
v1.8.0minor

99c0490: Tier caps can now require a minimum population: give a `tierCaps` entry `minInScope` and the tier can only be newly taken where the cap's scope holds at least that many published listings. The count covers published listings (`status: 'published'`) in the same market and, for a field-scoped cap, the same field value; it counts only the cap's `listingTypes` when set, and includes the listing being written when it is itself published. Below the minimum the write throws the new `TierScopeTooSmallError` (`code: 'tier-scope-too-small'`, HTTP 409, carrying `tier`, `scope`, `min` and `count`), exported from `@wabbit/tome-directory/server`. The rule applies to acquisition only: `assertTierCapsForListing` takes an optional `currentTier`, and when it equals the tier being written (a renewal or a re-stamp) the minimum is skipped while the `max` check still runs, so a sitting holder is never evicted when its scope shrinks. Both write paths (the listings hook and the subscription handlers) pass the listing's stored tier. Caps without `minInScope` behave exactly as before.

  • 99c0490: Tier caps can now require a minimum population: give a `tierCaps` entry `minInScope` and the tier can only be newly taken where the cap's scope holds at least that many published listings. The count covers published listings (`status: 'published'`) in the same market and, for a field-scoped cap, the same field value; it counts only the cap's `listingTypes` when set, and includes the listing being written when it is itself published. Below the minimum the write throws the new `TierScopeTooSmallError` (`code: 'tier-scope-too-small'`, HTTP 409, carrying `tier`, `scope`, `min` and `count`), exported from `@wabbit/tome-directory/server`. The rule applies to acquisition only: `assertTierCapsForListing` takes an optional `currentTier`, and when it equals the tier being written (a renewal or a re-stamp) the minimum is skipped while the `max` check still runs, so a sitting holder is never evicted when its scope shrinks. Both write paths (the listings hook and the subscription handlers) pass the listing's stored tier. Caps without `minInScope` behave exactly as before.
v1.7.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v1.7.0minor

09f80cb: A menu push can now be hardened without breaking existing integrations: one bad item no longer aborts the push, and a site can gate, validate, size-limit and snapshot-replace what a store publishes. Every addition is optional, so an existing config behaves identically; the response only gains a `rejected` list when something was rejected. - Per-item validation. Each pushed item is checked on its own, and the response lists rejected items with reasons (`{ externalId, index, reasons }`) beside `accepted` and `skipped`. The new `validatePublishMenuItems` is exported from `./menu`. - `DirectoryLayerConfig.menuPublish` takes `entitlementGate` (a public 403 unless it returns true), `validateItem` (a per-item callback, e.g. a content scan), `maxItemsPerPush` and `maxBodyBytes` (413), `itemLimits`, and `mode: 'replace'`, where a push replaces that listing's push-sourced rows and never touches owner-sourced ones. - `revokeMenuPublishKey` disables publishing and clears the key, the counterpart to `mintMenuPublishKey`. - `publishMenu` throws a public `APIError` (403, `menu-publish-disabled`) when publishing is off, and the endpoint now returns public errors with their own status and code. - `menuPublish.ttlSources` limits the hourly purge to rows whose `source` carries a TTL, so owner-typed rows with a consumer-managed retention date are never purged. Omitted, the job still purges every expired row. * An unchanged re-pushed item now refreshes its `publishedAt` and `expiresAt` (still counted as `skipped`), so a store pushing the same menu daily no longer has it age out at the TTL. * Security: the client address can no longer be spoofed through `x-forwarded-for`. The events rate limit and the claims terms-acceptance IP used the leftmost entry, which the caller chooses. They now use `trustedProxyHops` entries from the right (default 1, the rightmost, which the nearest proxy appended). New `DirectoryLayerConfig.clientIp(req)` lets a site supply the address from a header its edge guarantees, and takes precedence; `trustedProxyHops: 0` ignores forwarding headers. Behavior change: behind a CDN plus a load balancer the default now sees the CDN-side address, so set `trustedProxyHops: 2` or a `clientIp` callback to keep per-client rate limiting and an accurate terms-acceptance IP. The default is 1 hop because it is the only value that is safe without knowing the deployment, and a wrong guess towards too few hops only coarsens the limiter, while too many would trust client input. * Security: the menu-publish body is now capped before it is buffered. `Content-Length` is checked first, then the body is read as a stream and aborted with a 413 the moment it passes the cap. When `menuPublish.maxBodyBytes` is unset the endpoint now applies a built-in 5 MB ceiling instead of being unbounded; a site that pushes larger menus must raise `maxBodyBytes`. * Security: the menu-publish endpoint no longer reveals which authentication check failed. An unknown listing (was 404), publishing disabled or no key set, a wrong key id (was 403) and a bad or stale signature (was 401 with a `reason`) all return the same 401 `{ "error": "authentication failed" }`, and the early exits do the same HMAC work so timing does not differ either. The reason is logged server-side. The entitlement gate stays a 403 (it runs after authentication), and 413 and 429 are unchanged. Publishers that branched on the old 404 or 403 must treat 401 as "check the listing id, key and signature".

  • 09f80cb: A menu push can now be hardened without breaking existing integrations: one bad item no longer aborts the push, and a site can gate, validate, size-limit and snapshot-replace what a store publishes. Every addition is optional, so an existing config behaves identically; the response only gains a `rejected` list when something was rejected. - Per-item validation. Each pushed item is checked on its own, and the response lists rejected items with reasons (`{ externalId, index, reasons }`) beside `accepted` and `skipped`. The new `validatePublishMenuItems` is exported from `./menu`. - `DirectoryLayerConfig.menuPublish` takes `entitlementGate` (a public 403 unless it returns true), `validateItem` (a per-item callback, e.g. a content scan), `maxItemsPerPush` and `maxBodyBytes` (413), `itemLimits`, and `mode: 'replace'`, where a push replaces that listing's push-sourced rows and never touches owner-sourced ones. - `revokeMenuPublishKey` disables publishing and clears the key, the counterpart to `mintMenuPublishKey`. - `publishMenu` throws a public `APIError` (403, `menu-publish-disabled`) when publishing is off, and the endpoint now returns public errors with their own status and code. - `menuPublish.ttlSources` limits the hourly purge to rows whose `source` carries a TTL, so owner-typed rows with a consumer-managed retention date are never purged. Omitted, the job still purges every expired row. * An unchanged re-pushed item now refreshes its `publishedAt` and `expiresAt` (still counted as `skipped`), so a store pushing the same menu daily no longer has it age out at the TTL. * Security: the client address can no longer be spoofed through `x-forwarded-for`. The events rate limit and the claims terms-acceptance IP used the leftmost entry, which the caller chooses. They now use `trustedProxyHops` entries from the right (default 1, the rightmost, which the nearest proxy appended). New `DirectoryLayerConfig.clientIp(req)` lets a site supply the address from a header its edge guarantees, and takes precedence; `trustedProxyHops: 0` ignores forwarding headers. Behavior change: behind a CDN plus a load balancer the default now sees the CDN-side address, so set `trustedProxyHops: 2` or a `clientIp` callback to keep per-client rate limiting and an accurate terms-acceptance IP. The default is 1 hop because it is the only value that is safe without knowing the deployment, and a wrong guess towards too few hops only coarsens the limiter, while too many would trust client input. * Security: the menu-publish body is now capped before it is buffered. `Content-Length` is checked first, then the body is read as a stream and aborted with a 413 the moment it passes the cap. When `menuPublish.maxBodyBytes` is unset the endpoint now applies a built-in 5 MB ceiling instead of being unbounded; a site that pushes larger menus must raise `maxBodyBytes`. * Security: the menu-publish endpoint no longer reveals which authentication check failed. An unknown listing (was 404), publishing disabled or no key set, a wrong key id (was 403) and a bad or stale signature (was 401 with a `reason`) all return the same 401 `{ "error": "authentication failed" }`, and the early exits do the same HMAC work so timing does not differ either. The reason is logged server-side. The entitlement gate stays a 403 (it runs after authentication), and 413 and 429 are unchanged. Publishers that branched on the old 404 or 403 must treat 401 as "check the listing id, key and signature".
  • ab5a194: Tier-cap, entitlement and content-scan refusals now reach the person who triggered them instead of a generic 500 "Something went wrong". These refusals threw plain `Error`s, which Payload masks. They are now Payload `APIError`s with `isPublic: true` and a status: 409 for a full tier slot, a taken slug, or a photo or live-promotion cap; 403 for a tier that is not eligible for Deal of the Day; 400 for a missing end date, an unscoped cap field, or prohibited content. Each carries a machine-readable `data.code`: `photo-cap-exceeded`, `promotion-cap-exceeded`, `deal-of-day-ineligible`, `promotion-end-date-required`, `prohibited-content`, `slug-taken`. `TierSlotUnavailableError`, `TierSlotScopeMissingError` and `PremierSlotUnavailableError` now extend `APIError`. They keep their names, `code`s, fields and `instanceof` behavior, and every message is unchanged. No exports or signatures change.
v1.6.0minor

e5ee256: A directory with several listing types can now give each type its own entitlement ladder and its own, type-scoped slot caps, and can hold listing photos for staff review. Every addition is optional, so an existing config behaves identically. - **Per-type ladders.** `tenantPolicy.entitlementsByListingType` maps a listing type to its own complete tier table. A listing resolves against its type's table, then `entitlements`, then `DEFAULT_ENTITLEMENTS`. `ListingBillingSnapshot` gains an optional `listingType`; every internal caller passes it, and a lookup that is handed a listing without one logs a one-time development warning when per-type ladders are configured. `respondToReview` accepts `tenantPolicy` or `entitlementsByListingType`. - **Type-scoped, multiple caps.** `DirectoryTierCap` gains `listingTypes`, and a `tierCaps` value may be one cap or a list. A scoped cap applies to, and counts, only its own listing types. This also fixes a latent over-count: featured listings of one type no longer used up another type's slots once the cap is scoped. New exports: `assertTierCapsForListing`, `capsForTier`, and a `listingTypes` filter on `assertTierSlotAvailable`. - **Fail-closed field scope.** A field-scoped cap whose field is empty on the listing now throws `TierSlotScopeMissingError` (`code: 'tier-slot-scope-missing'`) instead of querying for a missing value. - **Analytics keys.** `DirectoryLayerConfig.analytics` accepts `extraViewSources`, `extraSponsoredSurfaces` and `outboundKinds`. The events endpoint accepts them and a new `outbound` event type with a validated `kind`; `directory-listing-stats` builds its groups from the built-ins plus your keys and adds `outboundClicks` only when you set `outboundKinds`. `getListingStats` returns the extra keys plus `sponsoredImpressions`, `sponsoredClicks` and `outboundClicks`. - **Photo pre-review.** `tenantPolicy.content.listingPhotoReview` holds new photos for the named listing types in a locked `pendingPhotos` field until staff call the new `approveListingPhoto` or `rejectListingPhoto`. `maxPhotos` counts both fields, and an owner's direct write to `photos` can only remove photos for a reviewed type. `attachListingPhoto` gains `bypassReview` and a `placement` result. - **Content scanner.** `scanForProhibitedContent` and `assertNoProhibitedContent` are now exported. Type note: `DirectoryTierCaps` values widen from `DirectoryTierCap` to `DirectoryTierCap | readonly DirectoryTierCap[]`. Config literals still compile; code that reads a cap's fields straight off `tierCaps[tier]` must now handle the list form (use `capsForTier`). The `viewSources` type on stats rows and results widens to include any registered key. Storage note: the new `pendingPhotos`, review and `outboundClicks` fields exist only when you opt in, so an existing schema is unchanged. A SQL-backed consumer needs a migration when it first adds them.

  • e5ee256: A directory with several listing types can now give each type its own entitlement ladder and its own, type-scoped slot caps, and can hold listing photos for staff review. Every addition is optional, so an existing config behaves identically. - **Per-type ladders.** `tenantPolicy.entitlementsByListingType` maps a listing type to its own complete tier table. A listing resolves against its type's table, then `entitlements`, then `DEFAULT_ENTITLEMENTS`. `ListingBillingSnapshot` gains an optional `listingType`; every internal caller passes it, and a lookup that is handed a listing without one logs a one-time development warning when per-type ladders are configured. `respondToReview` accepts `tenantPolicy` or `entitlementsByListingType`. - **Type-scoped, multiple caps.** `DirectoryTierCap` gains `listingTypes`, and a `tierCaps` value may be one cap or a list. A scoped cap applies to, and counts, only its own listing types. This also fixes a latent over-count: featured listings of one type no longer used up another type's slots once the cap is scoped. New exports: `assertTierCapsForListing`, `capsForTier`, and a `listingTypes` filter on `assertTierSlotAvailable`. - **Fail-closed field scope.** A field-scoped cap whose field is empty on the listing now throws `TierSlotScopeMissingError` (`code: 'tier-slot-scope-missing'`) instead of querying for a missing value. - **Analytics keys.** `DirectoryLayerConfig.analytics` accepts `extraViewSources`, `extraSponsoredSurfaces` and `outboundKinds`. The events endpoint accepts them and a new `outbound` event type with a validated `kind`; `directory-listing-stats` builds its groups from the built-ins plus your keys and adds `outboundClicks` only when you set `outboundKinds`. `getListingStats` returns the extra keys plus `sponsoredImpressions`, `sponsoredClicks` and `outboundClicks`. - **Photo pre-review.** `tenantPolicy.content.listingPhotoReview` holds new photos for the named listing types in a locked `pendingPhotos` field until staff call the new `approveListingPhoto` or `rejectListingPhoto`. `maxPhotos` counts both fields, and an owner's direct write to `photos` can only remove photos for a reviewed type. `attachListingPhoto` gains `bypassReview` and a `placement` result. - **Content scanner.** `scanForProhibitedContent` and `assertNoProhibitedContent` are now exported. Type note: `DirectoryTierCaps` values widen from `DirectoryTierCap` to `DirectoryTierCap | readonly DirectoryTierCap[]`. Config literals still compile; code that reads a cap's fields straight off `tierCaps[tier]` must now handle the list form (use `capsForTier`). The `viewSources` type on stats rows and results widens to include any registered key. Storage note: the new `pendingPhotos`, review and `outboundClicks` fields exist only when you opt in, so an existing schema is unchanged. A SQL-backed consumer needs a migration when it first adds them.
v1.5.0minor

0181593: **BREAKING:** `@wabbit/tome-local` is a new required peer. `computeOpenState` now comes from `@wabbit/tome-local/hours`, the shared open-state utility. **Migration:** install `@wabbit/tome-local` (`>=0.1.0 <1.0.0`) alongside `@wabbit/tome-directory`. No code change is needed: - `computeOpenState`, `ComputeOpenStateArgs`, `OpenState` and `normalizeOverrideDate` are still exported. - Every call site keeps its signature. - The stored `hours` / `hoursOverrides` shape is unchanged. - **Relation to 1.4.1:** the split-shift and override-date fixes shipped in 1.4.1 carry over unchanged. All of 1.4.1's open-state tests pass against the shared utility. - **Fix — DST spring-forward gap.** A window that crossed the spring-forward gap (for example 18:00–02:00 on the change night) closed an hour early. It now resolves by checking the offsets on both sides of the change. Ambiguous fall-back times resolve as before. - **Fix — touching windows.** Windows that meet at midnight merge, so a place open through midnight reports its real closing time. - **Additive:** - `OpenState` gains `closesAt`, `current`, `next` and `closedLabel`. - `OpenWindow` is exported from `./server`. - `DirectoryDayOfWeek` is now an alias of the identical `LocalDayOfWeek` union. - **Unchanged:** save-time hours validation keeps its current lenient semantics. The stricter wrap-aware `validateWeeklyHours` in `@wabbit/tome-local/hours` is opt-in.

  • 0181593: **BREAKING:** `@wabbit/tome-local` is a new required peer. `computeOpenState` now comes from `@wabbit/tome-local/hours`, the shared open-state utility. **Migration:** install `@wabbit/tome-local` (`>=0.1.0 <1.0.0`) alongside `@wabbit/tome-directory`. No code change is needed: - `computeOpenState`, `ComputeOpenStateArgs`, `OpenState` and `normalizeOverrideDate` are still exported. - Every call site keeps its signature. - The stored `hours` / `hoursOverrides` shape is unchanged. - **Relation to 1.4.1:** the split-shift and override-date fixes shipped in 1.4.1 carry over unchanged. All of 1.4.1's open-state tests pass against the shared utility. - **Fix — DST spring-forward gap.** A window that crossed the spring-forward gap (for example 18:00–02:00 on the change night) closed an hour early. It now resolves by checking the offsets on both sides of the change. Ambiguous fall-back times resolve as before. - **Fix — touching windows.** Windows that meet at midnight merge, so a place open through midnight reports its real closing time. - **Additive:** - `OpenState` gains `closesAt`, `current`, `next` and `closedLabel`. - `OpenWindow` is exported from `./server`. - `DirectoryDayOfWeek` is now an alias of the identical `LocalDayOfWeek` union. - **Unchanged:** save-time hours validation keeps its current lenient semantics. The stricter wrap-aware `validateWeeklyHours` in `@wabbit/tome-local/hours` is opt-in.
v1.4.1patch

615b309: Fixes split-shift hours and holiday-override date matching in listing open/closed status. A listing with split-shift hours (e.g. a lunch break) now correctly shows open during EVERY window for the day, not just the first one. Holiday/exception overrides now correctly match the calendar day the admin UI's date picker actually stores, instead of silently never matching. Both bugs were in `computeOpenState` (`server/openNow.ts`), which every open-now computation in this package funnels through — `findListingsNear`, `getListingBySlug`, and `getOpenNowCount`. No API or field-shape change; existing `hours`/`hoursOverrides` data does not need to be migrated. A new exported helper, `normalizeOverrideDate`, handles the override-date normalization and is documented for anyone matching `hoursOverrides` dates directly. The admin UI's `hoursOverrides.date` field now also hides its time picker (`pickerAppearance: 'dayOnly'`), since the stored value was never meant to carry a time of day.

  • 615b309: Fixes split-shift hours and holiday-override date matching in listing open/closed status. A listing with split-shift hours (e.g. a lunch break) now correctly shows open during EVERY window for the day, not just the first one. Holiday/exception overrides now correctly match the calendar day the admin UI's date picker actually stores, instead of silently never matching. Both bugs were in `computeOpenState` (`server/openNow.ts`), which every open-now computation in this package funnels through — `findListingsNear`, `getListingBySlug`, and `getOpenNowCount`. No API or field-shape change; existing `hours`/`hoursOverrides` data does not need to be migrated. A new exported helper, `normalizeOverrideDate`, handles the override-date normalization and is documented for anyone matching `hoursOverrides` dates directly. The admin UI's `hoursOverrides.date` field now also hides its time picker (`pickerAppearance: 'dayOnly'`), since the stored value was never meant to carry a time of day.
v1.4.0minor

2651355: **BREAKING:** promotion cap/eligibility now apply to every actor, including staff. New `DirectoryLayerConfig.tierCaps` generalizes the Premier-only slot cap to any tier and scope. `assertTierSlotAvailable` (new export, `@wabbit/tome-directory/server`) generalizes the previously-uncalled `assertPremierSlotAvailable` (kept, unchanged, as a thin wrapper) to any tier, scoped to a market or to one dot-path field within a market (e.g. one Featured slot per city), with any max. `DirectoryLayerConfig.tierCaps` configures it — default when omitted: `{ premier: { scope: 'market', max: 1 } }`, matching today's documented intent. The check now actually runs, in two write paths: `registerDirectorySubscriptionHandlers` (before stamping a subscription-driven tier change) and the listings `beforeChange` hook (whenever `tier` changes to a capped tier — staff admin stamps, manual billing). Both paths enforce the same `tierCaps` object; `createDirectoryLayer` forwards it to both automatically. **BREAKING:** `createPromotionBeforeValidateHook`'s live-count cap (`maxLivePromotions`) and `dealOfDay` eligibility check used to be skipped outright for a staff or system-authored write. They now apply to every actor — a staff admin approving or publishing an owner's promotion must respect the owner's own tier cap. The only bypass is an explicit `req.context.bypassPromotionCap === true`, set on purpose (a migration or one-off internal script), never inferred from `systemWrite` or a staff role. **Migration:** a consumer with a staff/admin flow that publishes promotions on an owner's behalf and relies on it skipping the cap must now set `context: { bypassPromotionCap: true }` on that specific write if bypassing is still wanted; otherwise the cap now applies as it always should have. Promotion-cap/eligibility and review-response entitlement checks are now billing-resolved: both `createPromotionBeforeValidateHook` and `respondToReview` resolve entitlements through `resolveListingEntitlements` (the same billing-aware path photos/analytics already used), instead of a static lookup keyed by the stored `tier` field. A lapsed subscription now loses these perks immediately, even though the listing's stored `tier` is still stamped high, rather than waiting for the grace-expiry job to catch up. `findListingsNear`'s decorated listings (and `@wabbit/tome-blocks-directory-pack`'s map pins, via `resolveDirectoryMapPins`) now carry an additive `resolvedTier` field — the billing-resolved tier, which can differ from the stored `tier`. A consumer reading a decorated listing's tier for display should prefer `resolvedTier ?? tier`.

  • 2651355: **BREAKING:** promotion cap/eligibility now apply to every actor, including staff. New `DirectoryLayerConfig.tierCaps` generalizes the Premier-only slot cap to any tier and scope. `assertTierSlotAvailable` (new export, `@wabbit/tome-directory/server`) generalizes the previously-uncalled `assertPremierSlotAvailable` (kept, unchanged, as a thin wrapper) to any tier, scoped to a market or to one dot-path field within a market (e.g. one Featured slot per city), with any max. `DirectoryLayerConfig.tierCaps` configures it — default when omitted: `{ premier: { scope: 'market', max: 1 } }`, matching today's documented intent. The check now actually runs, in two write paths: `registerDirectorySubscriptionHandlers` (before stamping a subscription-driven tier change) and the listings `beforeChange` hook (whenever `tier` changes to a capped tier — staff admin stamps, manual billing). Both paths enforce the same `tierCaps` object; `createDirectoryLayer` forwards it to both automatically. **BREAKING:** `createPromotionBeforeValidateHook`'s live-count cap (`maxLivePromotions`) and `dealOfDay` eligibility check used to be skipped outright for a staff or system-authored write. They now apply to every actor — a staff admin approving or publishing an owner's promotion must respect the owner's own tier cap. The only bypass is an explicit `req.context.bypassPromotionCap === true`, set on purpose (a migration or one-off internal script), never inferred from `systemWrite` or a staff role. **Migration:** a consumer with a staff/admin flow that publishes promotions on an owner's behalf and relies on it skipping the cap must now set `context: { bypassPromotionCap: true }` on that specific write if bypassing is still wanted; otherwise the cap now applies as it always should have. Promotion-cap/eligibility and review-response entitlement checks are now billing-resolved: both `createPromotionBeforeValidateHook` and `respondToReview` resolve entitlements through `resolveListingEntitlements` (the same billing-aware path photos/analytics already used), instead of a static lookup keyed by the stored `tier` field. A lapsed subscription now loses these perks immediately, even though the listing's stored `tier` is still stamped high, rather than waiting for the grace-expiry job to catch up. `findListingsNear`'s decorated listings (and `@wabbit/tome-blocks-directory-pack`'s map pins, via `resolveDirectoryMapPins`) now carry an additive `resolvedTier` field — the billing-resolved tier, which can differ from the stored `tier`. A consumer reading a decorated listing's tier for display should prefer `resolvedTier ?? tier`.
v1.3.1patch

77651c7: Directory listing pages work on a site without the Commerce engine. `getListingBySlug`, `findListingsNear`, `getLivePromotions` and `resolveListingEntitlements` read economy's `subscriptions` collection to decide whether a paid tier is live. Economy has been an optional peer since 1.3.0, so that collection may not exist, and the three queries threw on any listing with a paid tier. They now skip the lookup when the collection isn't registered. A paid tier then resolves as it does with no subscription row: the unpaid floor, unless the owner account's billing provider is `manual`, which keeps the tier.

  • 77651c7: Directory listing pages work on a site without the Commerce engine. `getListingBySlug`, `findListingsNear`, `getLivePromotions` and `resolveListingEntitlements` read economy's `subscriptions` collection to decide whether a paid tier is live. Economy has been an optional peer since 1.3.0, so that collection may not exist, and the three queries threw on any listing with a paid tier. They now skip the lookup when the collection isn't registered. A paid tier then resolves as it does with no subscription row: the unpaid floor, unless the owner account's billing provider is `manual`, which keeps the tier.
v1.3.0minor

cbd3961: `@wabbit/tome-economy` is now an optional peer, so you can install the directory without the Commerce engine; paid listing tiers require it. **If you sell paid listing tiers (one-line change on upgrade):** install `@wabbit/tome-economy` yourself, import `registerDirectorySubscriptionHandlers` (and its `RegisterDirectorySubscriptionHandlersArgs`/`RegisterDirectorySubscriptionHandlersResult` types) from `@wabbit/tome-directory/economy` instead of the package root, and pass it as the second argument: `directory.registerHandlers.registerEconomySubscriptionHandlers(payload, registerDirectorySubscriptionHandlers)`. If you don't sell paid tiers, remove that call. - The package root no longer re-exports `registerDirectorySubscriptionHandlers` or its types, and nothing reachable from the root (or from `./server`, `./maps`, `./menu`, `./endpoints`) imports `@wabbit/tome-economy`. Only `./economy` does. - `registerEconomySubscriptionHandlers` now takes the registrar as a required second argument (typed as the new `DirectoryEconomySubscriptionRegistrar`). Called without one, it logs a one-time warning that paid listing tiers are disabled and returns a no-op `unsubscribe`, rather than throwing. - Manually stamped tiers and fail-secure entitlement resolution are unchanged.

  • cbd3961: `@wabbit/tome-economy` is now an optional peer, so you can install the directory without the Commerce engine; paid listing tiers require it. **If you sell paid listing tiers (one-line change on upgrade):** install `@wabbit/tome-economy` yourself, import `registerDirectorySubscriptionHandlers` (and its `RegisterDirectorySubscriptionHandlersArgs`/`RegisterDirectorySubscriptionHandlersResult` types) from `@wabbit/tome-directory/economy` instead of the package root, and pass it as the second argument: `directory.registerHandlers.registerEconomySubscriptionHandlers(payload, registerDirectorySubscriptionHandlers)`. If you don't sell paid tiers, remove that call. - The package root no longer re-exports `registerDirectorySubscriptionHandlers` or its types, and nothing reachable from the root (or from `./server`, `./maps`, `./menu`, `./endpoints`) imports `@wabbit/tome-economy`. Only `./economy` does. - `registerEconomySubscriptionHandlers` now takes the registrar as a required second argument (typed as the new `DirectoryEconomySubscriptionRegistrar`). Called without one, it logs a one-time warning that paid listing tiers are disabled and returns a no-op `unsubscribe`, rather than throwing. - Manually stamped tiers and fail-secure entitlement resolution are unchanged.
v1.2.1patch

c8335a3: Nearby-listing search and the live-promotions query no longer issue unbounded reads; results are unchanged. Promotions and subscriptions are read in bounded pages, and the listing fallback lookup is capped at the number of ids requested. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.

  • c8335a3: Nearby-listing search and the live-promotions query no longer issue unbounded reads; results are unchanged. Promotions and subscriptions are read in bounded pages, and the listing fallback lookup is capped at the number of ids requested. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
  • 3fdb656: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v1.2.0minor

c3215e4: **BREAKING for anonymous callers:** `POST /directory/claims` now requires a signed-in session and takes the claimant from it, never from the request body. **The defect (P0):** the handler never read `req.user`. It took `claimantMember` and `acceptedTerms.ip` from the JSON body and passed them to `submitClaim`, which writes with `overrideAccess: true`. `createDirectoryLayer` mounts the route by default, so any anonymous caller could file a pending claim on any listing in any member's name. Only one pending claim is allowed per listing, so that fake claim then locked the real owner out. **The fix:** - The claimant is resolved with `resolveCallerMember` (`src/access/context.ts`), the same wrapper over `@wabbit/tome-core/identity`'s `resolveMemberFromSession` that every directory access rule uses. It looks up the `members` row whose `user` is `req.user.id`. `createDirectoryEndpoints` gains an optional `membersSlug`, default `'members'` (core's default), and `createDirectoryLayer` passes `DirectoryLayerConfig.membersSlug`. - No session user → `401`. A session user with no member row → `403`. A body `claimantMember` that is not the resolved member id → `403`. The endpoint refuses the mismatch and never rewrites it. Omitting `claimantMember` is fine. - `acceptedTerms.ip` comes from `x-forwarded-for` (first hop), then `x-real-ip`, else `'unknown'`. - `acceptedTerms.acceptedAt` is now stamped by the server at submission. The submission is the moment of acceptance, and a client clock can be backdated, so the stamp is better evidence. Body-supplied `acceptedAt` and `ip` are ignored. Only `acceptedTerms.version` still comes from the caller, and it is still checked against the current agreement version. **Why minor, not patch:** the request contract changes for one class of caller. Anonymous requests that used to get `201` now get `401`. `acceptedTerms.ip`/`acceptedAt` are no longer honored from the body. The endpoint also sends `403` in cases where it used to write. The package's own history flags runtime-contract changes (`1.0.0` for `claimed`/`unpaid`), so shipping this silently as a patch would understate it. It is not `major`, because no legitimate caller loses anything. An authenticated caller that sends its own member id, like the first consumer tenant's `submitClaimAction`, keeps working unchanged. The only TypeScript change is the new optional `membersSlug`. **Consumer action:** a server-side caller that posts to this endpoint on a visitor's behalf must forward the visitor's `cookie` header, as it already had to for the claim to belong to anyone. It should also forward `x-forwarded-for`, or `acceptedTerms.ip` records the server's own egress address. Also in this change: the other three directory endpoints (menu publish, age gate, events) are marked `// public-endpoint: <reason>`, and a new repo gate, `pnpm assert:endpoint-auth`, fails any endpoint under `packages/*/src/**/endpoints/` that neither reads the session nor declares itself public.

  • c3215e4: **BREAKING for anonymous callers:** `POST /directory/claims` now requires a signed-in session and takes the claimant from it, never from the request body. **The defect (P0):** the handler never read `req.user`. It took `claimantMember` and `acceptedTerms.ip` from the JSON body and passed them to `submitClaim`, which writes with `overrideAccess: true`. `createDirectoryLayer` mounts the route by default, so any anonymous caller could file a pending claim on any listing in any member's name. Only one pending claim is allowed per listing, so that fake claim then locked the real owner out. **The fix:** - The claimant is resolved with `resolveCallerMember` (`src/access/context.ts`), the same wrapper over `@wabbit/tome-core/identity`'s `resolveMemberFromSession` that every directory access rule uses. It looks up the `members` row whose `user` is `req.user.id`. `createDirectoryEndpoints` gains an optional `membersSlug`, default `'members'` (core's default), and `createDirectoryLayer` passes `DirectoryLayerConfig.membersSlug`. - No session user → `401`. A session user with no member row → `403`. A body `claimantMember` that is not the resolved member id → `403`. The endpoint refuses the mismatch and never rewrites it. Omitting `claimantMember` is fine. - `acceptedTerms.ip` comes from `x-forwarded-for` (first hop), then `x-real-ip`, else `'unknown'`. - `acceptedTerms.acceptedAt` is now stamped by the server at submission. The submission is the moment of acceptance, and a client clock can be backdated, so the stamp is better evidence. Body-supplied `acceptedAt` and `ip` are ignored. Only `acceptedTerms.version` still comes from the caller, and it is still checked against the current agreement version. **Why minor, not patch:** the request contract changes for one class of caller. Anonymous requests that used to get `201` now get `401`. `acceptedTerms.ip`/`acceptedAt` are no longer honored from the body. The endpoint also sends `403` in cases where it used to write. The package's own history flags runtime-contract changes (`1.0.0` for `claimed`/`unpaid`), so shipping this silently as a patch would understate it. It is not `major`, because no legitimate caller loses anything. An authenticated caller that sends its own member id, like the first consumer tenant's `submitClaimAction`, keeps working unchanged. The only TypeScript change is the new optional `membersSlug`. **Consumer action:** a server-side caller that posts to this endpoint on a visitor's behalf must forward the visitor's `cookie` header, as it already had to for the claim to belong to anyone. It should also forward `x-forwarded-for`, or `acceptedTerms.ip` records the server's own egress address. Also in this change: the other three directory endpoints (menu publish, age gate, events) are marked `// public-endpoint: <reason>`, and a new repo gate, `pnpm assert:endpoint-auth`, fails any endpoint under `packages/*/src/**/endpoints/` that neither reads the session nor declares itself public.
  • 36ec595: **BREAKING:** `DEFAULT_DIRECTORY_ACCESS` — what every directory collection factory falls back to when called without `access` — now fails closed: every operation is staff-only (`directoryStaff`: `super-admin`/`admin`/`staff`). It was all `() => true`, so a consumer who composed the exported factories without `createDirectoryLayer` shipped an open CRUD surface. `createDirectoryLayer` is unaffected (it always passes the real bundle with its status-filtered public reads). A standalone factory caller that relied on the open default must now pass `access`.
  • b06a193: **BREAKING:** `isStaffOrAdmin(req, context?)` is now async (`Promise<boolean>`) and delegates to `isStaffUser` — the same staff definition access control uses. It previously used the deprecated sync `checkRole(['staff','admin'])`, which excluded `super-admin` (a super-admin's edits to locked listing fields were silently stripped, and super-admins hit owner photo caps and content scans), ignored `staffPredicate`, and returned `false` whenever roles were not already populated. `DirectoryHookContext`, `ResolveHookContextArgs`, and the listings/promotions collection configs gain an optional `staffPredicate`, which `createDirectoryLayer` threads through so hook-side decisions honor the consumer override. Callers of the exported `isStaffOrAdmin` must `await` it.
  • f7486a8: **BREAKING:** license re-verification is now wired through the layer, and the job is omitted rather than silently no-opping. Decision: `DirectoryLayerConfig` gains an optional `registryVerify` (the site's license-register lookup, type `DirectoryRegistryVerifier`), which `createDirectoryLayer` threads to `createDirectoryJobs`. When it is absent, `createDirectoryJobs` (and therefore the layer) no longer includes the `reverifyRegistry` job at all — previously the layer always shipped it with no verifier, so every run skipped while the job list implied licenses were being re-verified — and `createDirectoryLayer` warns once at creation if `tenantPolicy.registry.requireVerifiedLicenseForTypes` is non-empty. A consumer that mounted the `reverifyRegistry` endpoint from `layer.jobs` must supply `registryVerify` to keep it.
  • 44b39f3: Relationship ids are now read with core's `relationId` / `relationIdRaw` (`@wabbit/tome-core/utilities/relationId`) instead of seven local copies; each site keeps its return shape, and the public `relId` export stays as a thin alias of `relationIdRaw`. The `@wabbit/tome-core` peer floor rises to `>=1.17.0` (the release that adds `relationIdRaw`).
  • 4314473: `findListingsNear` and `getOpenNowCount` no longer issue unbounded `limit: 0` reads on public request paths. `findListingsNear` adds a portable lat/lng bounding-box pre-filter (new `boundingBoxForRadius` in `server/geo`) on every path and pages the listings read with core's `findPaged` (200 rows per query, capped and logged); `getOpenNowCount` pages the same way. Results are unchanged — the box strictly contains the search circle and the haversine post-filter still decides membership.
v1.1.0minor

6d54319: Add owner analytics, placement reporting, and photo attestation. New public API: `directory-listing-stats` and `directory-photo-attestations` collections; `POST /directory/events` ingestion endpoint (bot deny-list, a consumer-pluggable `DirectoryLayerConfig.isVerifiedCrawler` hook, per-IP in-memory rate limiting, always 204); server queries `getListingStats`, `getListingPlacement`, and `getRotationShare` (all entitlement-checked, `null` when the listing's plan lacks the relevant entitlement); `attachListingPhoto`/`detachListingPhoto` for the claim-time photo license grant; a daily `pruneListingStats` job (400-day retention). Privacy by construction: no IP, user-agent, cookie id, member id, or session id is ever stored with an event — counts only, keyed on `[listing, day]` in the listing's local timezone. A sponsored impression/click can only be recorded for a listing that is currently `sponsored` per its resolved entitlements, so the counters can never say a surface was labeled Sponsored when it wasn't. This makes the `analytics` entitlement (declared since v1 but previously unbacked by any data) real for the first time.

  • 6d54319: Add owner analytics, placement reporting, and photo attestation. New public API: `directory-listing-stats` and `directory-photo-attestations` collections; `POST /directory/events` ingestion endpoint (bot deny-list, a consumer-pluggable `DirectoryLayerConfig.isVerifiedCrawler` hook, per-IP in-memory rate limiting, always 204); server queries `getListingStats`, `getListingPlacement`, and `getRotationShare` (all entitlement-checked, `null` when the listing's plan lacks the relevant entitlement); `attachListingPhoto`/`detachListingPhoto` for the claim-time photo license grant; a daily `pruneListingStats` job (400-day retention). Privacy by construction: no IP, user-agent, cookie id, member id, or session id is ever stored with an event — counts only, keyed on `[listing, day]` in the listing's local timezone. A sponsored impression/click can only be recorded for a listing that is currently `sponsored` per its resolved entitlements, so the counters can never say a surface was labeled Sponsored when it wasn't. This makes the `analytics` entitlement (declared since v1 but previously unbacked by any data) real for the first time.
v1.0.1patch

42046fa: Fix the five scheduled jobs' (`createDirectoryJobs` / each `create*Job`) default `endpoint.path` being double-prefixed with `/api` at request time. **Root cause:** every job defaulted its `endpoint.path` to `/api/directory/jobs/<job>`, but Payload mounts config-root `endpoints` under `/api` itself — the layer's other endpoints (`/directory/claims`, `/directory/age-gate/verify`, `/directory/menus/:listingId/publish`) never included the `/api` prefix themselves. Verified empirically on tome-starter (`next start`, 2026-09-13): `POST /api/directory/jobs/expire-promotions` → "Route not found"; `POST /api/api/directory/jobs/expire-promotions` → 401 without the bearer, 200 with it. Every consumer spreading `directoryLayer.jobs.map(j => j.endpoint)` into a cron/scheduler config (a production consumer does exactly this) got double-prefixed cron URLs. **The fix:** `expirePromotions`, `expireTierGrace`, `purgeExpiredMenuItems`, `recomputeRatingAggregates`, and `reverifyRegistry` now default to `/directory/jobs/<job>` — matching every other endpoint this layer mounts. `createDirectoryJobs`'s aggregate output changes accordingly; a caller who already set an explicit `path` override is unaffected. **Consumer-visible change:** the default cron endpoint URLs move from `/api/api/directory/jobs/*` (broken) to `/api/directory/jobs/*` (working). A consumer that had already worked around the double-prefix (e.g. by pointing its scheduler at the broken `/api/api/...` URL, or by passing an explicit `path`) should update its scheduler config to the corrected URL, or keep its explicit `path` override — either continues to work.

  • 42046fa: Fix the five scheduled jobs' (`createDirectoryJobs` / each `create*Job`) default `endpoint.path` being double-prefixed with `/api` at request time. **Root cause:** every job defaulted its `endpoint.path` to `/api/directory/jobs/<job>`, but Payload mounts config-root `endpoints` under `/api` itself — the layer's other endpoints (`/directory/claims`, `/directory/age-gate/verify`, `/directory/menus/:listingId/publish`) never included the `/api` prefix themselves. Verified empirically on tome-starter (`next start`, 2026-09-13): `POST /api/directory/jobs/expire-promotions` → "Route not found"; `POST /api/api/directory/jobs/expire-promotions` → 401 without the bearer, 200 with it. Every consumer spreading `directoryLayer.jobs.map(j => j.endpoint)` into a cron/scheduler config (a production consumer does exactly this) got double-prefixed cron URLs. **The fix:** `expirePromotions`, `expireTierGrace`, `purgeExpiredMenuItems`, `recomputeRatingAggregates`, and `reverifyRegistry` now default to `/directory/jobs/<job>` — matching every other endpoint this layer mounts. `createDirectoryJobs`'s aggregate output changes accordingly; a caller who already set an explicit `path` override is unaffected. **Consumer-visible change:** the default cron endpoint URLs move from `/api/api/directory/jobs/*` (broken) to `/api/directory/jobs/*` (working). A consumer that had already worked around the double-prefix (e.g. by pointing its scheduler at the broken `/api/api/...` URL, or by passing an explicit `path`) should update its scheduler config to the corrected URL, or keep its explicit `path` override — either continues to work.
v1.0.0major

7050ab3: Introduce `unpaid` as a real unpaid floor tier beneath `claimed`, and make `claimed` sellable. This is a breaking behavioral change flagged `major` for review — the public API surface is purely additive, but the RUNTIME semantics of the `claimed` tier change. **The problem:** `claimed` was hardcoded as the layer's free floor in several billing-blind places — `resolveListingEntitlements` short-circuited on `tier === 'claimed'` and returned its entitlements BEFORE any subscription/billing check, the fail-secure `catch` returned full `claimed` entitlements on any error, and `SELLABLE_TIERS` excluded `claimed` entirely so nothing could ever stamp it via checkout. That made a paid entry tier (e.g. a $49/mo cannabis-directory claim fee) impossible to sell, and worse, meant every fail-secure path handed out `claimed`'s entitlements for free, forever. **The fix:** 1. New `DirectoryTier` member `'unpaid'` — zero marketing entitlements (`maxPhotos: 0`, `maxLivePromotions: 0`, `rankBoost: 0`, `ownerResponses: false`, etc.). It is the ONLY tier every fail-secure path (`resolveListingEntitlements`'s error catch and no-active-billing branch, `registerDirectorySubscriptionHandlers`'s cancel handler, `revokeClaim`, `expireTierGrace`) now lands on — never `claimed`. 2. `claimed` is added to `SELLABLE_TIERS` and its short-circuit in `resolveListingEntitlements` is removed — it now resolves through the EXACT same active-subscription/manual-billing gate as `listed`/`featured`/`premier`. 3. `directory-listings.tier`'s schema default changes from `'claimed'` to `'unpaid'` — a brand-new listing starts with zero entitlements until claimed and/or subscribed. 4. Ownership vs. entitlements is now an explicit split: `ownerAccount` (established by `approveClaim`) grants the right to correct FACTUAL fields (address, phone, hours, license number) with NO billing check, ever. Entitlements (photos, promotions, owner review responses, rank boost, menu sync, sponsored placement) are billing-gated. New `DirectoryEntitlements.ownerResponses: boolean` field — `moderation/respondToReview` now checks it (statically, off the listing's stored `tier`, mirroring the existing photo-cap pattern) before allowing a response write; `unpaid` is `false`, `claimed` and above are `true`. 5. New claim-payment seam: `approveClaim({ requirePayment })`. Default `false` preserves EVERY existing tenant's behavior byte-for-byte — the listing is stamped `tier: 'claimed'` in the same call that establishes ownership, and a brand-new owner account is stamped `billing.provider: 'manual'` so `claimed`'s entitlements actually resolve (reusing the pre-existing manual-billing bypass rather than inventing a parallel "free tier" concept). `requirePayment: true` leaves the listing at its existing tier (normally `unpaid`) and defers promotion to `claimed` to a subsequent `createSubscriptionCheckoutAction` + the (now claimed-aware) subscription-complete handler — no new economy wiring required, since adding `claimed` to `SELLABLE_TIERS` was the only change the checkout path needed. **Backward compatibility:** A tenant that never passes `requirePayment` and never configures `economy.tierPriceMap.claimed` sees IDENTICAL behavior to before this change — free claiming still grants `claimed`'s entitlements immediately, with no code changes required on the consumer side. **Migration flag for any tenant with PRE-EXISTING `claimed` listings from before this change ships:** those listings' owner accounts were never stamped `billing.provider: 'manual'` (the old code never checked it). After this deploy, `resolveListingEntitlements` will fail those listings secure to the `unpaid` floor until either (a) a backfill stamps `billing.provider: 'manual'` on their owner accounts, or (b) they're re-approved through the new `approveClaim` path. Flagging for the operator/consumer sites to run a one-time backfill before/alongside this deploy if any tenant already has claimed listings in production. Also updated `@wabbit/tome-blocks-directory-pack`'s `maps/shared/pins.ts` (`TIER_BACKGROUND`/`TIER_FOREGROUND` `Record<DirectoryTier, string>`) to add an `unpaid` entry so it keeps compiling against the widened `DirectoryTier` union — same muted styling `claimed` already had, since a pin's fill is a rank cue, not a claim-status cue.

  • 7050ab3: Introduce `unpaid` as a real unpaid floor tier beneath `claimed`, and make `claimed` sellable. This is a breaking behavioral change flagged `major` for review — the public API surface is purely additive, but the RUNTIME semantics of the `claimed` tier change. **The problem:** `claimed` was hardcoded as the layer's free floor in several billing-blind places — `resolveListingEntitlements` short-circuited on `tier === 'claimed'` and returned its entitlements BEFORE any subscription/billing check, the fail-secure `catch` returned full `claimed` entitlements on any error, and `SELLABLE_TIERS` excluded `claimed` entirely so nothing could ever stamp it via checkout. That made a paid entry tier (e.g. a $49/mo cannabis-directory claim fee) impossible to sell, and worse, meant every fail-secure path handed out `claimed`'s entitlements for free, forever. **The fix:** 1. New `DirectoryTier` member `'unpaid'` — zero marketing entitlements (`maxPhotos: 0`, `maxLivePromotions: 0`, `rankBoost: 0`, `ownerResponses: false`, etc.). It is the ONLY tier every fail-secure path (`resolveListingEntitlements`'s error catch and no-active-billing branch, `registerDirectorySubscriptionHandlers`'s cancel handler, `revokeClaim`, `expireTierGrace`) now lands on — never `claimed`. 2. `claimed` is added to `SELLABLE_TIERS` and its short-circuit in `resolveListingEntitlements` is removed — it now resolves through the EXACT same active-subscription/manual-billing gate as `listed`/`featured`/`premier`. 3. `directory-listings.tier`'s schema default changes from `'claimed'` to `'unpaid'` — a brand-new listing starts with zero entitlements until claimed and/or subscribed. 4. Ownership vs. entitlements is now an explicit split: `ownerAccount` (established by `approveClaim`) grants the right to correct FACTUAL fields (address, phone, hours, license number) with NO billing check, ever. Entitlements (photos, promotions, owner review responses, rank boost, menu sync, sponsored placement) are billing-gated. New `DirectoryEntitlements.ownerResponses: boolean` field — `moderation/respondToReview` now checks it (statically, off the listing's stored `tier`, mirroring the existing photo-cap pattern) before allowing a response write; `unpaid` is `false`, `claimed` and above are `true`. 5. New claim-payment seam: `approveClaim({ requirePayment })`. Default `false` preserves EVERY existing tenant's behavior byte-for-byte — the listing is stamped `tier: 'claimed'` in the same call that establishes ownership, and a brand-new owner account is stamped `billing.provider: 'manual'` so `claimed`'s entitlements actually resolve (reusing the pre-existing manual-billing bypass rather than inventing a parallel "free tier" concept). `requirePayment: true` leaves the listing at its existing tier (normally `unpaid`) and defers promotion to `claimed` to a subsequent `createSubscriptionCheckoutAction` + the (now claimed-aware) subscription-complete handler — no new economy wiring required, since adding `claimed` to `SELLABLE_TIERS` was the only change the checkout path needed. **Backward compatibility:** A tenant that never passes `requirePayment` and never configures `economy.tierPriceMap.claimed` sees IDENTICAL behavior to before this change — free claiming still grants `claimed`'s entitlements immediately, with no code changes required on the consumer side. **Migration flag for any tenant with PRE-EXISTING `claimed` listings from before this change ships:** those listings' owner accounts were never stamped `billing.provider: 'manual'` (the old code never checked it). After this deploy, `resolveListingEntitlements` will fail those listings secure to the `unpaid` floor until either (a) a backfill stamps `billing.provider: 'manual'` on their owner accounts, or (b) they're re-approved through the new `approveClaim` path. Flagging for the operator/consumer sites to run a one-time backfill before/alongside this deploy if any tenant already has claimed listings in production. Also updated `@wabbit/tome-blocks-directory-pack`'s `maps/shared/pins.ts` (`TIER_BACKGROUND`/`TIER_FOREGROUND` `Record<DirectoryTier, string>`) to add an `unpaid` entry so it keeps compiling against the widened `DirectoryTier` union — same muted styling `claimed` already had, since a pin's fill is a rank cue, not a claim-status cue.
  • 0636540: Fix a compliance defect in the age gate: the alternate age class (e.g. an 18+ medical-patient exception under a 21+ `minAge`) was granted to any visitor whose DOB fell in the alternate age band, with no assertion that they actually qualify for it — a plain "Enter" submission from an 18-20-year-old silently passed as `class: "alternate"`, making a 21+ gate behave as an 18+ gate for anyone who didn't notice the second button. `verifyAgeGate` now requires the caller to pass `claimAlternateClass: true` (asserted only when the visitor pressed the dedicated alternate-class control) in addition to meeting `policy.alternateClass.minAge`; an unclaimed under-`minAge` DOB is denied, never silently admitted. `createAgeGateVerifyHandler`'s request body gained an optional `claimAlternateClass` field (strict `=== true`, so a truthy-but-non-boolean value is never mistaken for a claim) — additive, so any pre-existing caller that omits it keeps getting `{ ok: false, class: null }` for an unclaimed under-`minAge` DOB, same as after this fix (they were never able to reach the alternate class via that path anyway, since the old code inferred it from age alone with no per-request signal at all). Also fixes the "Remember me on this device" checkbox being silently ignored: the handler's request body gained an optional `remember` field (default `true`, matching the prior always-persistent behavior) — `false` now issues a session cookie (no `Max-Age`/`Expires`) instead of always issuing a `policy.rememberDays`-lifetime cookie regardless of the visitor's choice. Both new fields are additive to the endpoint's request body — no existing caller's request shape needs to change, and the response shape (`{ ok, class }`) and `directory_ag` cookie payload shape are unchanged. `verifyAgeGate`'s `VerifyAgeGateArgs` gained an optional `claimAlternateClass` field — also additive. Tenants that configure no `ageGate.alternateClass` at all (a straight 21+ gate) are unaffected either way: the alternate-class branch has always been (and remains) a no-op when `policy.alternateClass` is undefined, claim or no claim — covered by dedicated tests since this is the exact configuration a production consumer is shipping.
  • f248c05: SECURITY: fix `directory-reviews` letting a review's own author self-approve their own pending review, and the same-shape gap in `directory-promotions`. **The vulnerability:** `buildReviewsAccess()` (`access/createDirectoryAccess.ts`) declares field-level locks for FOUR fields — `status`, `moderation`, `reports`, `ownerResponse` — but `createReviewsCollection` (`collections/reviews.ts`) only ever wired ONE of them (`moderation`) onto a real Payload field. The other three carried no `access` at all and fell through to the collection-level `update` access, which a review's own author legitimately holds on their OWN pending review (so they can edit `title`/`body` before moderation). Concretely: `PATCH /api/directory-reviews/<id> { status: 'approved' }` succeeded for the review's own author, publishing it unmoderated. `directory-promotions` had the identical shape: `buildPromotionsAccess()` declares a `fields.status` lock, but `createPromotionsCollection` never called `resolveDirectoryFieldAccess` for it at all — an owner updating their own `draft` promotion (access they legitimately hold) could set `status: 'live'` in the same PATCH, bypassing the staff/system-only publish transition. **The fix:** every key each bundle's `fields` map declares is now wired via `resolveDirectoryFieldAccess`, exactly as `moderation` already was. `ownerResponse` is wired to the bundle's existing (correct) predicate — system write, staff, OR the caller holding an owner seat on the review's own listing — deliberately NOT staff-only, since owner responses are a paid-tier product feature. **Also fixed, same class, opposite direction:** `directory-listings`' `ownerAccount` field has always called `resolveDirectoryFieldAccess(config.access, 'ownerAccount')`, but `DIRECTORY_LOCKED_LISTING_FIELDS` (`access/fieldLock.ts`) — the bundle's only source for its `fields` keys — never included `'ownerAccount'`, so that call always silently resolved to the permissive default. An owner could rewrite their own listing's `ownerAccount` directly (silent ownership/billing reassignment), with neither a field lock nor the `beforeChange` strip hook (which shares the same list) catching it. Now locked, same as every other server-stamped listing field. **Made the whole class loud, not just these four fields:** `resolveDirectoryFieldAccess` now THROWS at construction time if it's asked for a field key against a bundle whose `fields` map is populated but doesn't declare that key — the exact shape of the `ownerAccount` gap. A new generic test suite (`tests/collections/fieldLockWiring.test.ts`) builds every collection this package ships against its real access bundle and asserts, by function reference, that every key the bundle declares in `fields` is actually wired onto a real field — the exact shape of the `status`/`reports`/`ownerResponse` gap. Together these make "a bundle declares a field lock the collection never applies" and "a collection asks for a lock the bundle never declares" both fail immediately, for every current and future locked field, instead of silently doing nothing. **Backward compatibility — read carefully:** any consumer whose code was directly PATCHing `directory-reviews.status`/`.reports`/`.ownerResponse` or `directory-promotions.status` as an ordinary authenticated caller will now be denied where it previously succeeded. That is the point — those are exactly the unintended-write paths this closes. A consumer whose OWN code writes these fields via `overrideAccess: true` with `context: { systemWrite: true }` (the pattern this package's own `reportReview`/`respondToReview`/`approveReview`/`rejectReview`/`removeReview` already use) is unaffected; verified by reading each of those functions and by a spy-based test asserting every write they make carries both flags. `draft -> live`/`live -> expired` promotion transitions and `pending -> approved`/`rejected` review transitions go through `@wabbit/tome-workflow`'s `claimTransition`, which writes via the DB adapter's raw atomic primitive (bypassing Payload access entirely) or `overrideAccess: true` on its non-Mongo fallback — neither path is gated by Payload field access, so cron jobs (`expirePromotions`) and moderation helpers are unaffected either way.
  • e032576: Fix the staff access gate (`isStaffUser`/`directoryStaff` in `access/context.ts`), which was dead for every user of every role on every consumer. **The problem:** the gate read `req.user.role` — SINGULAR — via the deprecated `@wabbit/tome-core/access/checkRole`. Tome's real Users shape has no such field; roles live on `roles`, a relationship ARRAY resolved by BetterAuth's customSession enricher, never eagerly populated onto a flat `role` string. Every call resolved to `checkRole(undefined, [...])`, unconditionally `false`. Proven live in a consumer: an `admin`-role account still got `totalDocs: 0` and "Nothing found" against a six-document directory collection. Every collection's staff bypass, and every locked-field staff override (`tier`, `registryRef`, review moderation fields, promotion `status`, etc.), was silently inert — staff could not moderate, could not hand-correct a listing, could not bypass a scoping WHERE, regardless of role. **The fix:** 1. `isStaffUser` now delegates role resolution to `@wabbit/tome-core/auth/rbac`'s `checkRoleAsync` — the same helper core's own async admin gates (`auth/guards.ts`) use. It reads `_populatedRoles` when already request-cached, else `roles` (handling both bare relationship ids and already-populated role docs), fetching and caching on a miss. This makes the gate `async`; every call site was already inside an `async` `Access`/`FieldAccess` function, so nothing further up needed to change. 2. The default vocabulary no longer hardcodes `['staff', 'admin']` — that excluded `super-admin`, the platform's most privileged role, independent of the field-name bug. The default is now `super-admin` or `admin` (core's `ROLES` constants) plus a role literally slugged `staff` (kept only for a consumer who already defined one — never assumed to exist). 3. New `DirectoryLayerConfig.staffPredicate?: (req) => boolean | Promise<boolean>` lets a consumer replace the staff gate outright (a different vocabulary, an org-scoped check, whatever) — threaded through `createDirectoryLayer` into every `DirectoryAccessBundle` `createDirectoryAccess` builds, including the field-level locks. New exports `directoryStaffAccess(ctx)`/`lockedFieldAccessFor(ctx)` are the ctx-aware factories that honor it; the existing ctx-free `directoryStaff`/`directoryStaffFieldLevel`/`lockedFieldAccess` exports are unchanged in shape and always use the corrected default. **Backward compatibility:** essentially none of today's consumers are affected in practice, because the broken gate matched nobody — there is no live behavior to regress. Any consumer whose staff/admin/super-admin accounts start correctly bypassing scoping WHEREs and correctly editing locked fields is receiving the FIX, not a regression. A consumer that happens to have defined a role literally slugged `staff` continues to work unchanged (still in the default vocabulary). Field-level lock behavior for `directory-listings` locked fields (`buildListingFieldLocks`) is threaded through the same `ctx`/`staffPredicate` seam. Hook-side gating in `hooks/context.ts`'s `isStaffOrAdmin` (used by `beforeChange` field-strip enforcement) is a SEPARATE, already-correct implementation (it reads `roles`/`_populatedRoles` via core's sync `checkRole`, not a singular `role` field) — untouched here since it lives in files another in-flight PR (`feat/directory-unclaimed-floor-tier`) owns; it still hardcodes `['staff', 'admin']` (missing `super-admin`) and is flagged as a follow-up, not fixed in this change.
v0.1.1patch

e886c1b: Fix `validateAndNormalizeHours` rejecting `closes: '24:00'`: ISO 8601 permits `24:00` as an end-of-day value, and real register data closes stores at midnight this way, so the consumer import aborted on the first such listing. `24:00` is now accepted as a `closes` value only (never `opens`, and never any other value past `23:59`) and is treated as 1440 minutes — the day's final instant — for ordering and overlap validation. `computeOpenState` (`server/openNow.ts`) already resolved genuinely overnight windows (`closes` earlier than `opens`, e.g. `20:00`-`02:00`) correctly across the midnight wrap; it now also carries explicit documentation and test coverage for the `24:00` case, which resolves correctly via the same `Date.UTC` hour-24 overflow used for overnight wraps. The stored `hours` values are unchanged by this fix — no migration needed.

  • e886c1b: Fix `validateAndNormalizeHours` rejecting `closes: '24:00'`: ISO 8601 permits `24:00` as an end-of-day value, and real register data closes stores at midnight this way, so the consumer import aborted on the first such listing. `24:00` is now accepted as a `closes` value only (never `opens`, and never any other value past `23:59`) and is treated as 1440 minutes — the day's final instant — for ordering and overlap validation. `computeOpenState` (`server/openNow.ts`) already resolved genuinely overnight windows (`closes` earlier than `opens`, e.g. `20:00`-`02:00`) correctly across the midnight wrap; it now also carries explicit documentation and test coverage for the `24:00` case, which resolves correctly via the same `Date.UTC` hour-24 overflow used for overnight wraps. The stored `hours` values are unchanged by this fix — no migration needed.
v0.1.0minor

fa06026: New package: `@wabbit/tome-directory` — a reusable, market-agnostic, vertical-agnostic local-directory layer (markets, listings, claims, reviews, favorites, promotions, an inbound menu-item seam). Wave 1 (W1-INFRA) ships the full public type surface, the `defineListingType` registry, `defineDirectoryTenantPolicy` + `DEFAULT_ENTITLEMENTS`, the five-channel event bus, `createDirectoryLayer`'s config validation/slug resolution/listing-type-group composition/layer registration, and the `./server` + `./maps` export subpaths. The seven collection factories and every `./server` data-access function are typed stubs pending W1-COLL/W1-SERVER (Wave 1 continues in parallel). First tenant: a production consumer, via its own `DirectoryTenantPolicy` — no vertical-specific code ships in this package.

  • fa06026: New package: `@wabbit/tome-directory` — a reusable, market-agnostic, vertical-agnostic local-directory layer (markets, listings, claims, reviews, favorites, promotions, an inbound menu-item seam). Wave 1 (W1-INFRA) ships the full public type surface, the `defineListingType` registry, `defineDirectoryTenantPolicy` + `DEFAULT_ENTITLEMENTS`, the five-channel event bus, `createDirectoryLayer`'s config validation/slug resolution/listing-type-group composition/layer registration, and the `./server` + `./maps` export subpaths. The seven collection factories and every `./server` data-access function are typed stubs pending W1-COLL/W1-SERVER (Wave 1 continues in parallel). First tenant: a production consumer, via its own `DirectoryTenantPolicy` — no vertical-specific code ships in this package.

Lms

v0.26.2
v0.26.2patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values. - @wabbit/tome-gamification@0.3.6

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values. - @wabbit/tome-gamification@0.3.6
v0.26.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
  • Updated dependencies [deea397] - @wabbit/tome-gamification@0.3.6
v0.26.0minor

2a8bd41: Courses can carry outcomes, subjects and summaries, with a public outline, an activity feed and visit tracking. Adds opt-in course fields, a public course outline with totals, an activity feed, previous-visit tracking and a latest-assignments reader; every default schema except `AssignmentUpload` is unchanged. Additive changes, all off by default unless noted: - **`Course.subject`**: `createCourseCollection({ subjectOptions })` (or `createLmsLayer({ course: { subjectOptions } })`) adds an optional, indexed `subject` select after `level`. With no options the field is not added at all. An option value containing `:` throws at construction. - **`Course.outcomes`**: `outcomes: true` (factory, or `createLmsLayer({ course: { outcomes: true } })`) adds an `outcomes` array of `{ text }` rows (up to 12) after `description`. - **`Lesson.summary`**: `createLessonCollection({ summary: true })` (or `createLmsLayer({ lesson: { summary: true } })`) adds an optional `summary` textarea after `title`. - **`AssignmentUpload.reviewedAt`** (on by default): a read-only date at the end of the field list, stamped on update when an instructor adds feedback or sets a reviewer status (`graded`, `revision-required`; configurable with `reviewedStatuses`). A student resubmission never stamps it, create never stamps it, and existing rows stay `null` (no backfill). The stamp is a field-level hook on `reviewedAt`, so the collection's hook arrays are unchanged. Opt out of the stamp with `stampReviewedAt: false`. - **Learner visits**: `createLmsLayer({ learnerVisits: true })` adds two read-only dates to StudentProfile, `lastVisitedAt` and `previousVisitedAt`. `recordVisit(payload, memberId)` writes them (a visit is a run of requests with gaps under 30 minutes, configurable with `idleMinutes`) and `getPreviousVisit(payload, memberId)` returns the end of the previous visit. With the option off, both helpers are inert. - **`@wabbit/tome-lms/server`** exports `buildCourseItemTree(payload, courseId, options?)` (options: `completedLessonIds`, `includeUnlockAfter`, `courseItemsSlug`, `overrideAccess`, `req`; defaults match the previous internal behavior), `getCourseTotals(tree)`, `getActivitySince(payload, memberId, since, options?)` (replies, uploads sent back for another go, and certificates after a time, newest first), `getPreviousVisit`, `recordVisit`, `learnerVisitsEnabled` and `getStudentAssignments(payload, memberId, options?)` (the latest upload per lesson and assignment block), with their types. - **`@wabbit/tome-lms`** (root) also exports the pure `getCourseTotals` and `CourseTotals`, plus `createStampReviewedAt`, `isReviewWrite`, `DEFAULT_REVIEWED_STATUSES`, `createLearnerVisitFields`, `withLearnerVisitFields`, `LAST_VISITED_AT_FIELD` and `PREVIOUS_VISITED_AT_FIELD`. New columns: `assignment-uploads.reviewedAt` (date, nullable) always; `courses.subject` (text/enum, nullable), `courses.outcomes` (array of `text`), `lessons.summary` (text, nullable), `student-profiles.lastVisitedAt` and `student-profiles.previousVisitedAt` (date, nullable) only when their options are on. MongoDB needs no migration; a Postgres or SQLite site generates a migration for each column it turns on, and for `reviewedAt` on upgrade.

  • 2a8bd41: Courses can carry outcomes, subjects and summaries, with a public outline, an activity feed and visit tracking. Adds opt-in course fields, a public course outline with totals, an activity feed, previous-visit tracking and a latest-assignments reader; every default schema except `AssignmentUpload` is unchanged. Additive changes, all off by default unless noted: - **`Course.subject`**: `createCourseCollection({ subjectOptions })` (or `createLmsLayer({ course: { subjectOptions } })`) adds an optional, indexed `subject` select after `level`. With no options the field is not added at all. An option value containing `:` throws at construction. - **`Course.outcomes`**: `outcomes: true` (factory, or `createLmsLayer({ course: { outcomes: true } })`) adds an `outcomes` array of `{ text }` rows (up to 12) after `description`. - **`Lesson.summary`**: `createLessonCollection({ summary: true })` (or `createLmsLayer({ lesson: { summary: true } })`) adds an optional `summary` textarea after `title`. - **`AssignmentUpload.reviewedAt`** (on by default): a read-only date at the end of the field list, stamped on update when an instructor adds feedback or sets a reviewer status (`graded`, `revision-required`; configurable with `reviewedStatuses`). A student resubmission never stamps it, create never stamps it, and existing rows stay `null` (no backfill). The stamp is a field-level hook on `reviewedAt`, so the collection's hook arrays are unchanged. Opt out of the stamp with `stampReviewedAt: false`. - **Learner visits**: `createLmsLayer({ learnerVisits: true })` adds two read-only dates to StudentProfile, `lastVisitedAt` and `previousVisitedAt`. `recordVisit(payload, memberId)` writes them (a visit is a run of requests with gaps under 30 minutes, configurable with `idleMinutes`) and `getPreviousVisit(payload, memberId)` returns the end of the previous visit. With the option off, both helpers are inert. - **`@wabbit/tome-lms/server`** exports `buildCourseItemTree(payload, courseId, options?)` (options: `completedLessonIds`, `includeUnlockAfter`, `courseItemsSlug`, `overrideAccess`, `req`; defaults match the previous internal behavior), `getCourseTotals(tree)`, `getActivitySince(payload, memberId, since, options?)` (replies, uploads sent back for another go, and certificates after a time, newest first), `getPreviousVisit`, `recordVisit`, `learnerVisitsEnabled` and `getStudentAssignments(payload, memberId, options?)` (the latest upload per lesson and assignment block), with their types. - **`@wabbit/tome-lms`** (root) also exports the pure `getCourseTotals` and `CourseTotals`, plus `createStampReviewedAt`, `isReviewWrite`, `DEFAULT_REVIEWED_STATUSES`, `createLearnerVisitFields`, `withLearnerVisitFields`, `LAST_VISITED_AT_FIELD` and `PREVIOUS_VISITED_AT_FIELD`. New columns: `assignment-uploads.reviewedAt` (date, nullable) always; `courses.subject` (text/enum, nullable), `courses.outcomes` (array of `text`), `lessons.summary` (text, nullable), `student-profiles.lastVisitedAt` and `student-profiles.previousVisitedAt` (date, nullable) only when their options are on. MongoDB needs no migration; a Postgres or SQLite site generates a migration for each column it turns on, and for `reviewedAt` on upgrade.
  • bf447cf: Fixes level labels, active enrollments, the certificate count and instructor avatars in the LMS layer. Schoolroom platform fixes: level labels reach the Course collection, active enrollments and the certificate count return real data, and course instructors carry their avatars. **BREAKING:** the `EnrollmentStatus` type exported from `@wabbit/tome-lms/server` drops `active` and `suspended` (values CourseEnrollment never writes) and lists the real five; code comparing against them fails to typecheck. Behavior that returned nothing now returns data (see Consumer impact). - `createLmsLayer({ course: { levelOptions } })` now reaches the Course collection (it was dropped before, so a site could not set level labels such as Start, Core, Build through the layer). Zero-config, and a `course` object without any key set, still keep the static `CourseCollection` reference; the default level vocabulary is unchanged. - Correctness fix: "active" enrollments are now `enrolled` and `in-progress` (new `ACTIVE_ENROLLMENT_STATUSES`, the set the access guard, the course access hooks and the expiry sweep already use). `getActiveEnrollments` and the `enrollments.active` list and suggested next lessons of `getStudentDashboard` filtered on a `status: 'active'` value CourseEnrollment never writes, so they always returned nothing. The `EnrollmentStatus` type exported from `@wabbit/tome-lms/server` now lists the collection's real values (`enrolled | in-progress | completed | dropped | expired`); code comparing against `'active'` or `'suspended'` never matched and now fails to typecheck. A missing status is read as the collection default `enrolled` instead of `active`. - Correctness fix: `getStudentDashboard`'s `stats.totalCertificates` counted awards with `status: 'active'`, which no award has, so it was always 0. It now counts held awards: `status: 'valid'` and `approvalStatus` in `EFFECTIVE_AWARD_STATUSES` (approved, or a legacy unset row), the predicate the holder count and the activity feed use. - `getCourseLandingData` instructors now carry `avatar` from the populated staff user: an `avatar` upload, else `image` (a populated upload or an absolute or root-relative URL string, the Better Auth shape). Users without one still get `avatar: null`; the shape is unchanged. Consumer impact: sites will now see data where they saw none. The learner sidebar's active quest (`getActiveQuestData` in `@wabbit/tome-lms-ui/server`, which reads `getActiveEnrollments`) starts listing the member's live courses, and dashboard pages built on `getStudentDashboard` start showing active enrollments, suggested lessons and a non-zero certificate count. wabbit.com pins `@wabbit/tome-lms` `^0.24.0`, so nothing changes there until it bumps; check its learner pages (the authenticated layout's active quest section in the rail) at that bump.
v0.25.0minor

3d4e50c: **BREAKING:** in `enrollInCourse`, an expired certification no longer satisfies a certification prerequisite. A prerequisite course is now also met when the member holds the certification that course awards. **Migration:** members whose only qualifying award has `renewalStatus: 'expired'` are now blocked on hard courses (or warned on soft ones) until it is renewed. To keep the old behavior, renew those awards or switch the course's `prerequisiteStrictness` to `soft`. Previously only a completed enrollment counted, so legacy imports with the award but no enrollment were blocked (or warned, on soft courses). Completion is derived on read: no enrollment rows are written. This is a read-side derivation. This check is expiry-blind: an expired certification still bridges course completion. A CERT prerequisite is stricter and now also excludes expired awards: an award satisfies it when `approvalStatus` is `approved`, `null`, missing or `''` and `renewalStatus` is not `expired`. Pending and rejected awards never satisfy either kind of prerequisite. `prerequisiteStrictness` (hard blocks with `not-authorized`, soft emits `lms.course-enrollments.prereq-warning`) and the result and error shapes are unchanged. New exports: `getEffectiveCourseCompletion(payload, memberId, courseIds, slugs?)` and `getSatisfiedPrereqCertificationIds(payload, memberId, certificationIds, slugs?)` from `@wabbit/tome-lms/server` (batched, paginated, constant query count; the optional `EffectiveCompletionSlugs` lets a consumer that registered the enrollment, course or award collections under its own slugs point the reads at them), plus `PREREQ_CERT_AWARD_WHERE` (query form) and `awardSatisfiesPrereq` (in-memory form) from the package root and `@wabbit/tome-lms/utilities/heldCertifications`. Consumers that carry their own copies of this logic can switch to these.

  • 3d4e50c: **BREAKING:** in `enrollInCourse`, an expired certification no longer satisfies a certification prerequisite. A prerequisite course is now also met when the member holds the certification that course awards. **Migration:** members whose only qualifying award has `renewalStatus: 'expired'` are now blocked on hard courses (or warned on soft ones) until it is renewed. To keep the old behavior, renew those awards or switch the course's `prerequisiteStrictness` to `soft`. Previously only a completed enrollment counted, so legacy imports with the award but no enrollment were blocked (or warned, on soft courses). Completion is derived on read: no enrollment rows are written. This is a read-side derivation. This check is expiry-blind: an expired certification still bridges course completion. A CERT prerequisite is stricter and now also excludes expired awards: an award satisfies it when `approvalStatus` is `approved`, `null`, missing or `''` and `renewalStatus` is not `expired`. Pending and rejected awards never satisfy either kind of prerequisite. `prerequisiteStrictness` (hard blocks with `not-authorized`, soft emits `lms.course-enrollments.prereq-warning`) and the result and error shapes are unchanged. New exports: `getEffectiveCourseCompletion(payload, memberId, courseIds, slugs?)` and `getSatisfiedPrereqCertificationIds(payload, memberId, certificationIds, slugs?)` from `@wabbit/tome-lms/server` (batched, paginated, constant query count; the optional `EffectiveCompletionSlugs` lets a consumer that registered the enrollment, course or award collections under its own slugs point the reads at them), plus `PREREQ_CERT_AWARD_WHERE` (query form) and `awardSatisfiesPrereq` (in-memory form) from the package root and `@wabbit/tome-lms/utilities/heldCertifications`. Consumers that carry their own copies of this logic can switch to these.
v0.24.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
  • Updated dependencies [d51ad89] - @wabbit/tome-gamification@0.3.5
v0.24.0minor

0e5f063: **BREAKING:** Certificate lookups now hide the holder's name unless the learner opted in, and new certificate and enrollment fields need a database migration. **Migration:** Pass `{ holderName: 'always' }` to `getCertificateByVerificationId` to keep the old behavior. On SQL adapters generate and run a migration for the new fields (MongoDB needs none). Run `backfillVerificationCodes` once per consumer after migrating. Refunds now drop the active course enrollment; set `commerce: { courseAccessHooks: false }` to opt out. - `certification-awards` gains `verificationCode` (12 characters of Crockford Base32, read-only, generated on create, indexed non-unique in this release) and `showNameOnVerify` (the holder's opt-in, default off). - `certifications` gains `signatoryName` and `signatoryTitle`. `course-enrollments` gains `endedAt`, `endedReason` (`refund` / `admin` / `learner` / `expired`) and `sourceOrderId`. A learner-initiated `dropFromCourse` now stamps `endedAt` and `endedReason: 'learner'`. - `@wabbit/tome-lms/scripts/backfillVerificationCodes` gives existing awards a code. It is dry-run by default, writes through the database adapter without running collection hooks, and is idempotent. - `@wabbit/tome-lms/server` adds `configureCertificateVerification`, `getPublicCertificateVerification` (allow-list projection with `valid` / `expired` / `revoked` / `not-found` states), `getStudentCertificate`, `setCertificateNameVisibility`, `normalizeVerificationCode` and `formatVerificationCode`. - `createLmsLayer({ verification: { baseUrl, path } })` configures URLs, so certificate records now carry `verificationUrl` and a code-based `verificationId` (the document id only before the backfill). - `createLmsLayer` registers `getAccessSummary` and `revokeAccess` for the `course` product type. A refund drops the learner's active enrollment (`status: 'dropped'`, `endedReason: 'refund'`, progress kept) via economy's refund access policy. - `getCertificateByVerificationId` now accepts an options object. It looks up by code first, then by document id while `allowDocumentId` stays on (default `true` for this minor), and returns the recipient only when the holder opted in.

  • 0e5f063: **BREAKING:** Certificate lookups now hide the holder's name unless the learner opted in, and new certificate and enrollment fields need a database migration. **Migration:** Pass `{ holderName: 'always' }` to `getCertificateByVerificationId` to keep the old behavior. On SQL adapters generate and run a migration for the new fields (MongoDB needs none). Run `backfillVerificationCodes` once per consumer after migrating. Refunds now drop the active course enrollment; set `commerce: { courseAccessHooks: false }` to opt out. - `certification-awards` gains `verificationCode` (12 characters of Crockford Base32, read-only, generated on create, indexed non-unique in this release) and `showNameOnVerify` (the holder's opt-in, default off). - `certifications` gains `signatoryName` and `signatoryTitle`. `course-enrollments` gains `endedAt`, `endedReason` (`refund` / `admin` / `learner` / `expired`) and `sourceOrderId`. A learner-initiated `dropFromCourse` now stamps `endedAt` and `endedReason: 'learner'`. - `@wabbit/tome-lms/scripts/backfillVerificationCodes` gives existing awards a code. It is dry-run by default, writes through the database adapter without running collection hooks, and is idempotent. - `@wabbit/tome-lms/server` adds `configureCertificateVerification`, `getPublicCertificateVerification` (allow-list projection with `valid` / `expired` / `revoked` / `not-found` states), `getStudentCertificate`, `setCertificateNameVisibility`, `normalizeVerificationCode` and `formatVerificationCode`. - `createLmsLayer({ verification: { baseUrl, path } })` configures URLs, so certificate records now carry `verificationUrl` and a code-based `verificationId` (the document id only before the backfill). - `createLmsLayer` registers `getAccessSummary` and `revokeAccess` for the `course` product type. A refund drops the learner's active enrollment (`status: 'dropped'`, `endedReason: 'refund'`, progress kept) via economy's refund access policy. - `getCertificateByVerificationId` now accepts an options object. It looks up by code first, then by document id while `allowDocumentId` stays on (default `true` for this minor), and returns the recipient only when the holder opted in.
  • @wabbit/tome-gamification@0.3.4
v0.23.6patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
  • Updated dependencies [775f90a] - @wabbit/tome-gamification@0.3.4
v0.23.5patch

98b2d16: Existence and count checks now use `payload.count()` instead of reading every matching row; behavior is unchanged. Covers the enrollment, completion, credential and prerequisite guards, attempt numbering and dashboard totals. Challenge-mode prerequisite checks run with bounded concurrency instead of one at a time, and the course catalog no longer issues a duplicate enrollment read. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.

  • 98b2d16: Existence and count checks now use `payload.count()` instead of reading every matching row; behavior is unchanged. Covers the enrollment, completion, credential and prerequisite guards, attempt numbering and dashboard totals. Challenge-mode prerequisite checks run with bounded concurrency instead of one at a time, and the course catalog no longer issues a duplicate enrollment read. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
  • Updated dependencies [c8827e7]
  • Updated dependencies [bcdf9e5] - @wabbit/tome-gamification@0.3.3
v0.23.4patch

67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `relationshipId` and `server`'s `resolveRelationId` (public) are kept as deprecated delegates to `relationIdRaw(v, { polymorphic: true })`, with identical semantics. The internal `server/mutations` `extractId` is removed.

  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `relationshipId` and `server`'s `resolveRelationId` (public) are kept as deprecated delegates to `relationIdRaw(v, { polymorphic: true })`, with identical semantics. The internal `server/mutations` `extractId` is removed.
  • 30bdd74: The duck-typed role readers in `access/roles.ts` (`readRoles`, the tier match, and `resolveRoleSlugs`) now delegate to `@wabbit/tome-core/auth/rbac`. Behavior is unchanged: lms still memoizes under its own `req.context` key, and `readRoles` / `resolveRoleSlugs(req, userCollection?)` keep their signatures. `@wabbit/tome-gamification` keeps its own copy because core is an optional peer there. - @wabbit/tome-gamification@0.3.2
v0.23.3patch

feaf672: `gradeQuizAttempt` now reads quiz fields from the row's `blockData`, the shape `Lesson.blocks` actually persists (`{ blockType, blockData: json }` — Payload strips any other top-level key on write). Previously it read `questions`/`passingScore` flat off the row, so every stored quiz graded as 0/0 through the one-step path. It also accepts the two-step mutation's `quizBlock` block type alongside `quiz`. Flat in-memory rows still work.

  • feaf672: `gradeQuizAttempt` now reads quiz fields from the row's `blockData`, the shape `Lesson.blocks` actually persists (`{ blockType, blockData: json }` — Payload strips any other top-level key on write). Previously it read `questions`/`passingScore` flat off the row, so every stored quiz graded as 0/0 through the one-step path. It also accepts the two-step mutation's `quizBlock` block type alongside `quiz`. Flat in-memory rows still work.
v0.23.2patch

ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.

  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 670d2a1: **`approveOJT` gains the in-body capability check its siblings already had.** 2026-09-01 sale-readiness audit §4.2: `approveOJT` was the only instructor-gated mutation with no in-body capability assertion — `cooldown.ts` and `training-event.ts` both have one. `runMutation`'s capability step only fires when the CALLER declared `requiredCapabilities`; declaring none means "check nothing". So a mutation context assembled without either a declared capability or a `capabilityResolver` — the easiest one to assemble — could approve any other member's OJT sign-off and mint the LessonCompletion that follows. The self-approval guard did not cover this: it only blocks approving your OWN submission, which is the one thing an attacker does not need to do. The body now fails closed with `MutationInternalError('not-authorized', …)` unless the caller declared `instructor` or `admin`, or wired a resolver — the same shape and the same fail-closed rationale as the two siblings. The rejection happens before any `payload` read or write. Tests: 10 new assertions — rejection with no capabilities and no resolver, with an unrelated capability, with a resolver that reports the capability missing, and with an empty `requiredCapabilities` array (each verified to touch nothing); acceptance for `instructor` and for `admin`; the rejected-status path creating no LessonCompletion; and self-approval still blocked for an authorized instructor, including across string/number member-id types. Also recorded rather than silently changed: a caller that declares no capabilities but DOES wire a resolver still passes, because the resolver is never consulted when nothing is declared. That is the siblings' behavior too, it is the obvious next thing to tighten, and tightening it would change behavior for existing callers — so it has a test pinning it instead of an unreviewed edit.
  • 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.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behavior changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
  • Updated dependencies [4aeedad]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [73081e6] - @wabbit/tome-gamification@0.3.2
v0.23.1patch

HOTFIX (out-of-band from the 0.23.0 tag; fixes a live production TypeError). `CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG` was the string `'internal'` — a key `@payloadcms/richtext-lexical` RESERVES as its own object namespace on `req.context` (`context.internal.richText = {}`). Setting it to a boolean bypass broke richText validation on any certification the holder-count recount’s nested `payload.update` touched (`Cannot create property 'richText' on boolean 'true'`). The constant’s VALUE is now `'certAwardSystemBypass'` (constant NAME unchanged — consumers importing it migrate automatically on bump; every in-package SET site migrated: `autoAwardCertification`, `awardGate`). Defense in depth: `updateCertificationHolderCount` and `updateMemberCerts` now run their nested writes under a fresh `req.context` (save/restore in try/finally) so a triggering op’s flags never reach a different collection’s hooks. Red-first regression test reproduces lexical’s reserved write and the full award→recount chain. Published off a hotfix branch because main was mid-release for the sale-readiness audit (unpublished core 1.14.0 peer); the same fix commit is already on main.

  • HOTFIX (out-of-band from the 0.23.0 tag; fixes a live production TypeError). `CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG` was the string `'internal'` — a key `@payloadcms/richtext-lexical` RESERVES as its own object namespace on `req.context` (`context.internal.richText = {}`). Setting it to a boolean bypass broke richText validation on any certification the holder-count recount’s nested `payload.update` touched (`Cannot create property 'richText' on boolean 'true'`). The constant’s VALUE is now `'certAwardSystemBypass'` (constant NAME unchanged — consumers importing it migrate automatically on bump; every in-package SET site migrated: `autoAwardCertification`, `awardGate`). Defense in depth: `updateCertificationHolderCount` and `updateMemberCerts` now run their nested writes under a fresh `req.context` (save/restore in try/finally) so a triggering op’s flags never reach a different collection’s hooks. Red-first regression test reproduces lexical’s reserved write and the full award→recount chain. Published off a hotfix branch because main was mid-release for the sale-readiness audit (unpublished core 1.14.0 peer); the same fix commit is already on main.
v0.23.0minor

32afcac: Aftercare — three ledgered gaps closed: `QuizAttempt.feedback`, a `'graded'` status option, and revocation-aware holder-count/member-cert derivations. **GAP 1 — `QuizAttempt.feedback`.** Instructor essay-grading comments were accepted by EDU's `gradeSubmission` (`wabbit-site-core src/data/lms-instructor.ts`, `grade.feedback`) but had nowhere on this row to persist — confirmed absent from the factory's field set. Adds `feedback` (textarea, optional, additive) alongside `gradedBy`/`gradedAt`, mirroring `AssignmentUpload`'s flat `feedback` field rather than a nested `grade` group. Nothing writes it yet — same posture `gradedBy`/`gradedAt` shipped with. **GAP 2 — `'graded'` status option.** The 2026-08-31 design doc intended a dedicated "graded" terminal state; the shipped factory's vocabulary was `in-progress`/`completed`/`timed-out`, and EDU's grader closes essays to `'completed'` (indistinguishable from auto-graded). Adds `'graded'` (label "Graded (instructor)") as a fourth, additive `status` option — zero-config default/write behavior is unchanged (`submitQuizAttempt` and `grading.ts` still write `'completed'` on close-out; adopting `'graded'` for an instructor close-out is a consumer's own later port). Package-internal status comparisons were swept: `enforceAttemptPolicy`'s `outcomeOf` now treats `'graded'` as a third terminal outcome alongside `'completed'`/`'timed-out'` (a graded row already carries a real `passed` verdict, so excluding it would let a graded-and-failed essay attempt escape the maxAttempts/cooldown policy). `saveQuizProgress`'s `status !== 'in-progress'` guard already excludes `'graded'` correctly by construction (exclusion, not enumeration) — no change needed there. **GAP 3 — revocation-aware derivations.** Proven live on staging: a `status: 'revoked'` `CertificationAward` kept its `approvalStatus: 'approved'` (revocation never touches that field) and so was still counted by `updateCertificationHolderCount` and still derived into `Member.certificationAwards[]`/`certifications[]` by `updateMemberCerts` — both hooks filtered ONLY on `EFFECTIVE_AWARD_STATUSES` (`approvalStatus`-vocabulary). New export `EXCLUDE_REVOKED_WHERE` (`utilities/awardStatus.ts`) — a separately-composed `{ status: { not_equals: 'revoked' } }` fragment, ANDed alongside `EFFECTIVE_AWARD_STATUSES` in both hooks' queries, never folded into that constant (which stays exactly what every existing reader already relies on it meaning). Companion predicate `isRevokedAwardStatus` for in-memory checks. Deliberately does NOT touch `'expired'` — the `renewalStatus` state machine (`checkCertificationExpiry` sweep) owns that transition and already self-selects via `status: 'valid'`; a consumer wanting both exclusions composes `{ status: { not_equals: 'expired' } }` alongside this fragment the same way `attemptChallengeMode` (`server/mutations/challenge.ts`) already does by hand for its own prerequisite check. **Analysis (no code changed in wabbit-site-core).** `getPendingGrading`'s quiz-attempts branch filters only `{ status: { equals: 'completed' } }` — not essay-specific. It returns every completed attempt on the instructor's courses regardless of whether it ever had an essay question, and (since `gradeSubmission`'s quiz-attempts close-out re-writes `status: 'completed'`) an already-graded essay resurfaces in this same query forever. No consumer currently even renders the `essays` bucket (`instructor/page.tsx` only uses `pendingAssignments`). `'graded'` gives a future revision of that query and grader a clean terminal state to adopt — this PR does not perform that adoption. **Tests.** Red-first throughout: `outcomeOf`'s three new `'graded'`-terminal cases and both hooks' revoked-exclusion cases were verified failing against the pre-fix code before the fix landed. New/updated: `tests/quiz-attempt-seams.test.ts` (feedback field, additive status options, graded-terminal policy cases), `tests/quiz-attempt-characterisation.test.ts` (status-options pin updated to the new 4-option list — the enumerated exception to byte-identity), `tests/award-status.test.ts` (`EFFECTIVE_AWARD_STATUSES` unchanged pin + `EXCLUDE_REVOKED_WHERE`/`isRevokedAwardStatus` coverage), `tests/cert-holder-count-recount.test.ts` and `tests/cert-member-sync.test.ts` (revoked-excluded / expired-unchanged cases; their mock `Where` matchers gained `not_equals` support to exercise the real fragment). Full lms suite green (934, up from 916 baseline); `assert-node-loadable --every-file` unchanged (238/14/0, no new skips).

  • 32afcac: Aftercare — three ledgered gaps closed: `QuizAttempt.feedback`, a `'graded'` status option, and revocation-aware holder-count/member-cert derivations. **GAP 1 — `QuizAttempt.feedback`.** Instructor essay-grading comments were accepted by EDU's `gradeSubmission` (`wabbit-site-core src/data/lms-instructor.ts`, `grade.feedback`) but had nowhere on this row to persist — confirmed absent from the factory's field set. Adds `feedback` (textarea, optional, additive) alongside `gradedBy`/`gradedAt`, mirroring `AssignmentUpload`'s flat `feedback` field rather than a nested `grade` group. Nothing writes it yet — same posture `gradedBy`/`gradedAt` shipped with. **GAP 2 — `'graded'` status option.** The 2026-08-31 design doc intended a dedicated "graded" terminal state; the shipped factory's vocabulary was `in-progress`/`completed`/`timed-out`, and EDU's grader closes essays to `'completed'` (indistinguishable from auto-graded). Adds `'graded'` (label "Graded (instructor)") as a fourth, additive `status` option — zero-config default/write behavior is unchanged (`submitQuizAttempt` and `grading.ts` still write `'completed'` on close-out; adopting `'graded'` for an instructor close-out is a consumer's own later port). Package-internal status comparisons were swept: `enforceAttemptPolicy`'s `outcomeOf` now treats `'graded'` as a third terminal outcome alongside `'completed'`/`'timed-out'` (a graded row already carries a real `passed` verdict, so excluding it would let a graded-and-failed essay attempt escape the maxAttempts/cooldown policy). `saveQuizProgress`'s `status !== 'in-progress'` guard already excludes `'graded'` correctly by construction (exclusion, not enumeration) — no change needed there. **GAP 3 — revocation-aware derivations.** Proven live on staging: a `status: 'revoked'` `CertificationAward` kept its `approvalStatus: 'approved'` (revocation never touches that field) and so was still counted by `updateCertificationHolderCount` and still derived into `Member.certificationAwards[]`/`certifications[]` by `updateMemberCerts` — both hooks filtered ONLY on `EFFECTIVE_AWARD_STATUSES` (`approvalStatus`-vocabulary). New export `EXCLUDE_REVOKED_WHERE` (`utilities/awardStatus.ts`) — a separately-composed `{ status: { not_equals: 'revoked' } }` fragment, ANDed alongside `EFFECTIVE_AWARD_STATUSES` in both hooks' queries, never folded into that constant (which stays exactly what every existing reader already relies on it meaning). Companion predicate `isRevokedAwardStatus` for in-memory checks. Deliberately does NOT touch `'expired'` — the `renewalStatus` state machine (`checkCertificationExpiry` sweep) owns that transition and already self-selects via `status: 'valid'`; a consumer wanting both exclusions composes `{ status: { not_equals: 'expired' } }` alongside this fragment the same way `attemptChallengeMode` (`server/mutations/challenge.ts`) already does by hand for its own prerequisite check. **Analysis (no code changed in wabbit-site-core).** `getPendingGrading`'s quiz-attempts branch filters only `{ status: { equals: 'completed' } }` — not essay-specific. It returns every completed attempt on the instructor's courses regardless of whether it ever had an essay question, and (since `gradeSubmission`'s quiz-attempts close-out re-writes `status: 'completed'`) an already-graded essay resurfaces in this same query forever. No consumer currently even renders the `essays` bucket (`instructor/page.tsx` only uses `pendingAssignments`). `'graded'` gives a future revision of that query and grader a clean terminal state to adopt — this PR does not perform that adoption. **Tests.** Red-first throughout: `outcomeOf`'s three new `'graded'`-terminal cases and both hooks' revoked-exclusion cases were verified failing against the pre-fix code before the fix landed. New/updated: `tests/quiz-attempt-seams.test.ts` (feedback field, additive status options, graded-terminal policy cases), `tests/quiz-attempt-characterisation.test.ts` (status-options pin updated to the new 4-option list — the enumerated exception to byte-identity), `tests/award-status.test.ts` (`EFFECTIVE_AWARD_STATUSES` unchanged pin + `EXCLUDE_REVOKED_WHERE`/`isRevokedAwardStatus` coverage), `tests/cert-holder-count-recount.test.ts` and `tests/cert-member-sync.test.ts` (revoked-excluded / expired-unchanged cases; their mock `Where` matchers gained `not_equals` support to exercise the real fragment). Full lms suite green (934, up from 916 baseline); `assert-node-loadable --every-file` unchanged (238/14/0, no new skips).
v0.22.0minor

a0b626a: A-4a — typed attempt-reference fields on `completedModules` + `createLmsLayer` collection passthrough (the cross-reference the A-3 STOP correctly refused to mis-type). **`completedModules[]` gains two typed back-references.** QuizAttempt/AssignmentUpload (0.21.0, A-1) are this package's canonical attempt rows, but nothing on `CourseEnrollment.completedModules[]` pointed back at them — the array had only `submissionReference` (the original form-submission pointer from the donor consumer, untouched, stays hard-typed to `formSubmissionRelationTo`). Two new ADDITIVE, OPTIONAL, single-target relationship fields — `quizAttemptRef` (→ `quizAttemptRelationTo`, default `'quiz-attempts'`) and `assignmentUploadRef` (→ `assignmentUploadRelationTo`, default `'assignment-uploads'`) — are inserted right after `submissionReference`, emitted ONLY in `completionStore: 'completed-modules'` mode. Zero-config (`'lesson-completions'` mode, the default) is byte-identical — pinned in `tests/course-enrollment-characterisation.test.ts`, unmodified by this PR. No migration: absence on existing rows is the same grandfather marker this package already uses everywhere else a field's meaning didn't exist yet when the row was written. **`createLmsLayer` gains a per-collection config passthrough.** `config.collections?.quizAttempt`/`.assignmentUpload` accept the same `QuizAttemptCollectionConfig`/`AssignmentUploadCollectionConfig` shape those factories already take directly, letting a site pass a non-default config (custom slug, `studentRelationTo`, `extraFields`, `requireBlockIds`, etc.) straight through `createLmsLayer` instead of filtering the returned `collections` array and appending its own re-built replacement afterward — EDU's prior pattern, retired by this seam. Zero-config (both keys omitted, or `collections: {}`) keeps the exact byte-identical static `QuizAttemptCollection`/`AssignmentUploadCollection` singletons — REFERENCE-identical, not a fresh `createXCollection({})` call — so a site that never touches this knob sees no behavior change at all. Registration order is preserved: the configured collection lands at the same array position the static default occupied. Only these two collections are exposed here; a general per-collection config seam for the rest of the layer's factory-backed collections is a 1.0 question, not decided by this PR. **`lms-ui`'s `QuizAttemptStartResult` root-barrel export** (the other open item from the EDU wave) was verified, not redone — it was fixed in 0.10.2 (`packages/lms-ui/src/index.ts` already re-exports it from `./types`, guarded by `quiz-attempt-start-result-export.pin.test.ts`). No lms-ui changes in this PR. **Tests.** New: `tests/layer-collections-passthrough.test.ts` (zero-config reference-identity + factory-reaching seam tests for both collections) and a new describe block in `tests/course-enrollment-completed-modules.test.ts` (field presence/position/shape/relationTo-seam coverage for `quizAttemptRef`/`assignmentUploadRef`). Full lms suite green; no new skips in `assert-node-loadable --every-file`.

  • a0b626a: A-4a — typed attempt-reference fields on `completedModules` + `createLmsLayer` collection passthrough (the cross-reference the A-3 STOP correctly refused to mis-type). **`completedModules[]` gains two typed back-references.** QuizAttempt/AssignmentUpload (0.21.0, A-1) are this package's canonical attempt rows, but nothing on `CourseEnrollment.completedModules[]` pointed back at them — the array had only `submissionReference` (the original form-submission pointer from the donor consumer, untouched, stays hard-typed to `formSubmissionRelationTo`). Two new ADDITIVE, OPTIONAL, single-target relationship fields — `quizAttemptRef` (→ `quizAttemptRelationTo`, default `'quiz-attempts'`) and `assignmentUploadRef` (→ `assignmentUploadRelationTo`, default `'assignment-uploads'`) — are inserted right after `submissionReference`, emitted ONLY in `completionStore: 'completed-modules'` mode. Zero-config (`'lesson-completions'` mode, the default) is byte-identical — pinned in `tests/course-enrollment-characterisation.test.ts`, unmodified by this PR. No migration: absence on existing rows is the same grandfather marker this package already uses everywhere else a field's meaning didn't exist yet when the row was written. **`createLmsLayer` gains a per-collection config passthrough.** `config.collections?.quizAttempt`/`.assignmentUpload` accept the same `QuizAttemptCollectionConfig`/`AssignmentUploadCollectionConfig` shape those factories already take directly, letting a site pass a non-default config (custom slug, `studentRelationTo`, `extraFields`, `requireBlockIds`, etc.) straight through `createLmsLayer` instead of filtering the returned `collections` array and appending its own re-built replacement afterward — EDU's prior pattern, retired by this seam. Zero-config (both keys omitted, or `collections: {}`) keeps the exact byte-identical static `QuizAttemptCollection`/`AssignmentUploadCollection` singletons — REFERENCE-identical, not a fresh `createXCollection({})` call — so a site that never touches this knob sees no behavior change at all. Registration order is preserved: the configured collection lands at the same array position the static default occupied. Only these two collections are exposed here; a general per-collection config seam for the rest of the layer's factory-backed collections is a 1.0 question, not decided by this PR. **`lms-ui`'s `QuizAttemptStartResult` root-barrel export** (the other open item from the EDU wave) was verified, not redone — it was fixed in 0.10.2 (`packages/lms-ui/src/index.ts` already re-exports it from `./types`, guarded by `quiz-attempt-start-result-export.pin.test.ts`). No lms-ui changes in this PR. **Tests.** New: `tests/layer-collections-passthrough.test.ts` (zero-config reference-identity + factory-reaching seam tests for both collections) and a new describe block in `tests/course-enrollment-completed-modules.test.ts` (field presence/position/shape/relationTo-seam coverage for `quizAttemptRef`/`assignmentUploadRef`). Full lms suite green; no new skips in `assert-node-loadable --every-file`.
v0.21.0minor

fd71b9a: A-1 — attempt-collection factories + exam cluster, the D-2b assessment-convergence arc's first port. Converts `QuizAttempt`/`AssignmentUpload` from static `CollectionConfig` exports to `createQuizAttemptCollection`/`createAssignmentUploadCollection` factories, per the fieldShape/mergeHooks/per-verb-access discipline `CertificationAward.ts` (L-P3) established, and ships a new opt-in exam cluster (`createExamTicketCollection`/`createExamBypassRequestCollection`, D-4) modeled on a production consumer's own collections. **Pins first.** `tests/{quiz-attempt,assignment-upload}-characterisation.test.ts` were written and verified green against the pre-conversion static exports BEFORE any factory code landed (see that commit in this PR's history), then updated post-conversion to drop only the assertions the D-2b additions deliberately supersede (an exhaustive negative-existence check for fields that now exist) — every other pin still passes unchanged. One confirmed pre-existing gap the pins prove: `submitAssignment` (server/mutations/assignment.ts) already writes `assignmentBlockId` onto every submission today, but the collection declared no such field — Payload silently dropped it on every write until this port. **QuizAttempt.** `quizBlockId`'s tightening to `required: true` (closing the ledgered gap where multi-quiz lessons were indistinguishable) is gated behind `requireBlockIds` (default `false` in 0.x — the field stays optional, byte-identical) — **the 1.0 flip to `required: true` by default is recorded here as the trigger for that release**. `gradedBy` (relationship, target configurable via `gradedByRelationTo`, default `'users'`) and `gradedAt` (date) are additive/optional fields for the manual-essay grading path (mirrors `AssignmentUpload.grade.gradedBy`/`gradedAt`) — nothing writes them yet; a grading UI has somewhere to put results now. Opt-in `enforceAttemptPolicy` (default `false`) wires a new blockId-scoped, policy-aware `beforeChange` hook (`hooks/attempt-workflow/enforceAttemptPolicy.ts`) that recomputes `{attempts, lockedOut, cooldownExpiry}` purely from prior QuizAttempt rows (folding every graded prior attempt through `utilities/attemptPolicy.ts`'s `applyAttemptOutcome`, called verbatim — never reimplemented), assigns `attemptNumber` from that state, and throws (blocking the create) on lockout or an active cooldown. This hook runs BEFORE the pre-existing `autoIncrementAttemptNumber` (still wired unconditionally, scoped by student+lesson+course, unchanged) — its already-positive guard makes the old hook a no-op whenever the new one already assigned a number. **AssignmentUpload.** Three additive fields, all new — `generate:types` will show exactly these on a consumer that regenerates: `assignmentBlockId` (text, indexed; the field `submitAssignment` was already trying to write — see the pins), `attemptNumber` (number, factory-assigned), `previousUpload` (self-relationship, resubmission lineage — the versioned-rows model the design doc calls for, replacing today's always-update-in-place `submitAssignment`; wiring the mutation to actually create a new row per resubmission is A-2/EDU adoption, out of this PR's scope). `assignmentBlockId`'s required-tightening is gated by the same `requireBlockIds` option (default `false`), same 1.0-flip note as QuizAttempt. `attemptNumber`/`previousUpload` auto-assignment (`hooks/attempt-workflow/assignAssignmentAttemptNumber.ts`, scoped by student+lesson+assignmentBlockId) is wired **on by default** (`assignAttemptNumber: true`) — unlike QuizAttempt's policy hook, this only populates brand-new fields, so there is no pre-A-1 behavior to preserve by defaulting it off. **Mutations are untouched.** `startQuizAttempt`/`submitQuizAttempt`/`submitAssignment`/`gradeAssignment` (server/mutations) are not modified in this PR. Reconciliation verdict: `startQuizAttempt` always pre-supplies a positive `attemptNumber` on every create it issues, so neither the old nor the new attemptNumber hook's recompute branch is ever reached on that path, regardless of `enforceAttemptPolicy`'s value — the flag only affects direct collection writes that omit `attemptNumber` (admin UI, seed scripts, out-of-band API calls). Neither mutation enforces maxAttempts/cooldown today (a TODO in `startQuizAttempt` notes the omission), so there is no double-enforcement risk to reconcile in 0.x either. No conflict found; no STOP triggered. **Exam cluster (D-4), opt-in.** `createExamTicketCollection`/`createExamBypassRequestCollection` reproduce a production consumer's field shape (`ExamTickets`/`ExamBypassRequests`) — including the full six-value ticket lifecycle and the complete escalation-ladder provenance trail on bypass requests (`proposerScope`/`approverTier`/`singleSignature`/`guardOverrides`/`soleAuthorityContext`/`soleAuthorityJustification`). That consumer's own hooks (real-time broadcast, pool-task-close notifications, the `@wabbit/tome-workflow` bypass-authority topology mirror) and access gates (role/scope-scoped authority) are NOT ported — both reach into that consumer's own app code this platform package cannot depend on — they attach via `config.hooks`/`config.access`, same seam every other factory in this package exposes. **Neither collection is registered in `createLmsLayer`'s `baseCollections`** — a consumer spreads the factory output into their own `payload.config.ts` collections array alongside `createLmsLayer(...)`'s, opt-in like every other collection a site composes in only when it runs that workflow. **Subpaths.** New `./collections/quizAttempt`, `./collections/assignmentUpload`, `./collections/examTicket`, `./collections/examBypassRequest` — same poisoned-barrel-alternate-door pattern as the eight prior subpaths (all four collections' own module graphs are clean — none touch gamification), each with its own spawn-node loadability test. **Tests.** 784 → 896 lms tests green (112 new): `quiz-attempt-characterisation.test.ts` (20), `assignment-upload-characterisation.test.ts` (21), `quiz-attempt-seams.test.ts` (17 — field/access/hook seams, `enforceAttemptPolicy`'s attemptNumber assignment + lockout/cooldown blocking, both on and off), `assignment-upload-seams.test.ts` (16 — field/access/hook seams, `assignAssignmentAttemptNumber`'s attemptNumber + `previousUpload` linking), `exam-cluster-characterisation.test.ts` (18 — zero-config defaults, consumer-emulation-shape relation targets, seam injection, confirms neither collection is in `createLmsLayer`), 4 new `*-subpath-loadable.test.ts` (20). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 238 pass / 14 pre-existing skip / 0 fail (no new skips — the 18 new targets, the four subpaths' `index.{js,cjs}` files, all pass).

  • fd71b9a: A-1 — attempt-collection factories + exam cluster, the D-2b assessment-convergence arc's first port. Converts `QuizAttempt`/`AssignmentUpload` from static `CollectionConfig` exports to `createQuizAttemptCollection`/`createAssignmentUploadCollection` factories, per the fieldShape/mergeHooks/per-verb-access discipline `CertificationAward.ts` (L-P3) established, and ships a new opt-in exam cluster (`createExamTicketCollection`/`createExamBypassRequestCollection`, D-4) modeled on a production consumer's own collections. **Pins first.** `tests/{quiz-attempt,assignment-upload}-characterisation.test.ts` were written and verified green against the pre-conversion static exports BEFORE any factory code landed (see that commit in this PR's history), then updated post-conversion to drop only the assertions the D-2b additions deliberately supersede (an exhaustive negative-existence check for fields that now exist) — every other pin still passes unchanged. One confirmed pre-existing gap the pins prove: `submitAssignment` (server/mutations/assignment.ts) already writes `assignmentBlockId` onto every submission today, but the collection declared no such field — Payload silently dropped it on every write until this port. **QuizAttempt.** `quizBlockId`'s tightening to `required: true` (closing the ledgered gap where multi-quiz lessons were indistinguishable) is gated behind `requireBlockIds` (default `false` in 0.x — the field stays optional, byte-identical) — **the 1.0 flip to `required: true` by default is recorded here as the trigger for that release**. `gradedBy` (relationship, target configurable via `gradedByRelationTo`, default `'users'`) and `gradedAt` (date) are additive/optional fields for the manual-essay grading path (mirrors `AssignmentUpload.grade.gradedBy`/`gradedAt`) — nothing writes them yet; a grading UI has somewhere to put results now. Opt-in `enforceAttemptPolicy` (default `false`) wires a new blockId-scoped, policy-aware `beforeChange` hook (`hooks/attempt-workflow/enforceAttemptPolicy.ts`) that recomputes `{attempts, lockedOut, cooldownExpiry}` purely from prior QuizAttempt rows (folding every graded prior attempt through `utilities/attemptPolicy.ts`'s `applyAttemptOutcome`, called verbatim — never reimplemented), assigns `attemptNumber` from that state, and throws (blocking the create) on lockout or an active cooldown. This hook runs BEFORE the pre-existing `autoIncrementAttemptNumber` (still wired unconditionally, scoped by student+lesson+course, unchanged) — its already-positive guard makes the old hook a no-op whenever the new one already assigned a number. **AssignmentUpload.** Three additive fields, all new — `generate:types` will show exactly these on a consumer that regenerates: `assignmentBlockId` (text, indexed; the field `submitAssignment` was already trying to write — see the pins), `attemptNumber` (number, factory-assigned), `previousUpload` (self-relationship, resubmission lineage — the versioned-rows model the design doc calls for, replacing today's always-update-in-place `submitAssignment`; wiring the mutation to actually create a new row per resubmission is A-2/EDU adoption, out of this PR's scope). `assignmentBlockId`'s required-tightening is gated by the same `requireBlockIds` option (default `false`), same 1.0-flip note as QuizAttempt. `attemptNumber`/`previousUpload` auto-assignment (`hooks/attempt-workflow/assignAssignmentAttemptNumber.ts`, scoped by student+lesson+assignmentBlockId) is wired **on by default** (`assignAttemptNumber: true`) — unlike QuizAttempt's policy hook, this only populates brand-new fields, so there is no pre-A-1 behavior to preserve by defaulting it off. **Mutations are untouched.** `startQuizAttempt`/`submitQuizAttempt`/`submitAssignment`/`gradeAssignment` (server/mutations) are not modified in this PR. Reconciliation verdict: `startQuizAttempt` always pre-supplies a positive `attemptNumber` on every create it issues, so neither the old nor the new attemptNumber hook's recompute branch is ever reached on that path, regardless of `enforceAttemptPolicy`'s value — the flag only affects direct collection writes that omit `attemptNumber` (admin UI, seed scripts, out-of-band API calls). Neither mutation enforces maxAttempts/cooldown today (a TODO in `startQuizAttempt` notes the omission), so there is no double-enforcement risk to reconcile in 0.x either. No conflict found; no STOP triggered. **Exam cluster (D-4), opt-in.** `createExamTicketCollection`/`createExamBypassRequestCollection` reproduce a production consumer's field shape (`ExamTickets`/`ExamBypassRequests`) — including the full six-value ticket lifecycle and the complete escalation-ladder provenance trail on bypass requests (`proposerScope`/`approverTier`/`singleSignature`/`guardOverrides`/`soleAuthorityContext`/`soleAuthorityJustification`). That consumer's own hooks (real-time broadcast, pool-task-close notifications, the `@wabbit/tome-workflow` bypass-authority topology mirror) and access gates (role/scope-scoped authority) are NOT ported — both reach into that consumer's own app code this platform package cannot depend on — they attach via `config.hooks`/`config.access`, same seam every other factory in this package exposes. **Neither collection is registered in `createLmsLayer`'s `baseCollections`** — a consumer spreads the factory output into their own `payload.config.ts` collections array alongside `createLmsLayer(...)`'s, opt-in like every other collection a site composes in only when it runs that workflow. **Subpaths.** New `./collections/quizAttempt`, `./collections/assignmentUpload`, `./collections/examTicket`, `./collections/examBypassRequest` — same poisoned-barrel-alternate-door pattern as the eight prior subpaths (all four collections' own module graphs are clean — none touch gamification), each with its own spawn-node loadability test. **Tests.** 784 → 896 lms tests green (112 new): `quiz-attempt-characterisation.test.ts` (20), `assignment-upload-characterisation.test.ts` (21), `quiz-attempt-seams.test.ts` (17 — field/access/hook seams, `enforceAttemptPolicy`'s attemptNumber assignment + lockout/cooldown blocking, both on and off), `assignment-upload-seams.test.ts` (16 — field/access/hook seams, `assignAssignmentAttemptNumber`'s attemptNumber + `previousUpload` linking), `exam-cluster-characterisation.test.ts` (18 — zero-config defaults, consumer-emulation-shape relation targets, seam injection, confirms neither collection is in `createLmsLayer`), 4 new `*-subpath-loadable.test.ts` (20). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 238 pass / 14 pre-existing skip / 0 fail (no new skips — the 18 new targets, the four subpaths' `index.{js,cjs}` files, all pass).
v0.20.0minor

ef97756: L-P8 — Course/Topic/CourseItem convergence, the LMS convergence program's LAST pair port. Ports the field-seam discipline established by L-P3 through L-P7 onto Course, Topic, and CourseItem — the largest and last of the seven pairs, deliberately sequenced last because it carries the donor consumer's biggest migration cost (array→junction rewrite). That consumer's own `Courses`/`Topics` collections are unmodified (read-only reference). **The structural decision, reaffirmed, not re-litigated.** CourseItem junction rows are canonical course structure — this was already true pre-port (Course carried no `topics`/`lessons` arrays, CourseItem already existed) and remains true post-port. This pair's actual work was porting the field-shape/access/mergeHooks discipline onto all three collections and re-expressing the donor consumer's incident-encoded Course invariants over junction rows, not a structural migration on this package's own side. **Field seams.** `collections/{Course,Topic,CourseItem}.ts` convert from static `CollectionConfig` exports to `createCourseCollection`/`createTopicCollection`/`createCourseItemCollection`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as every prior port. All three static exports remain `create*Collection()` with every default — byte-identical wiring, pinned by `tests/{course,topic,course-item}-characterisation.test.ts` (49 tests total, committed separately BEFORE any conversion landed, verified green against both the pre-port static collections and the post-port factory output). Course gains: `staffRelationTo` (owner/createdBy/instructors/maintainers together, default `'users'` unchanged — the donor consumer targets `'members'`), `mediaRelationTo`/`academyRelationTo`/`certificationRelationTo`/`courseRelationTo`/`skillPathRelationTo` (every remaining relationship target, per design principle 3), every select vocabulary made injectable, `versionsMode: 'none' | 'drafts'` (`'drafts'` wires the donor consumer's exact `versions: { drafts: true }` plus a `readVersions` default, matching the same trap `Lesson.ts`'s `versionsMode: 'snapshot-history'` already guards against), the standard field-shape pipeline, per-verb `access`, and a `hooks` merge seam. Topic gains the three polymorphic `prerequisites[].item` leg seams (`lessonRelationTo`/`topicRelationTo`/`courseRelationTo`) plus the standard pipeline — no built-in hooks (none pre-port). CourseItem gains `courseRelationTo`/`topicRelationTo`/`lessonRelationTo`, an injectable `itemTypeOptions` vocabulary, and the standard pipeline; `parent`/`unlockAfter` now self-reference the collection's own configured `slug` rather than a hardcoded string, so a consumer's slug rename carries them automatically. CourseItem's unique/secondary indexes are field-name-based and were already rename-safe pre-port. **Org-generic invariants from the donor consumer ship as opt-in factory hooks, default OFF.** `requirePublishedLessonsOnPublish` (`hooks/workflow/requirePublishedLessonsOnPublish.ts`) re-expresses that consumer's "a course cannot publish with zero published lessons" gate over CourseItem rows instead of direct arrays — walks every lesson-type CourseItem for the course (both top-level and nested under a topic), checks `Lesson.isPublished`, blocks the transition into `status: 'published'` when the published count is zero. Update-only by construction (a create-as-published course has no CourseItem rows referencing it yet, so it always fails the check the same way the pre-port donor's own update-only gate did). `dropEnrollmentsOnDelete` (`hooks/workflow/dropEnrollmentsOnDelete.ts`) drops (not deletes) a deleted course's live enrollments, paginated with the same 50-pass/zero-progress-break guard as the donor's original. Both take injectable relation-slug options (`courseItemRelationTo`/`lessonRelationTo`/`enrollmentRelationTo`) and both share this package's unified `req.context.internal` system-bypass convention (`../cert-workflow/enforceApprovalStatus.ts`'s constant) rather than the donor's own `migrationBackfill` flag name. Enabled via `invariantHooks: { requirePublishedLessonsOnPublish: true, dropEnrollmentsOnDelete: true }` — both default `false`, so zero-config output is unaffected. **Per-built-in-hook opt-outs from birth (the L-P5.1 lesson, applied on day one).** `promoteOwnerToMaintainerOnApproval` — which already existed on Course pre-port, unconditionally wired — is now opt-outable via `builtInHooks.promoteOwnerToMaintainerOnApproval` (default `true`, byte-identical). This exists because `mergeHooks` only appends: the donor consumer's real hook order requires `requireReapprovalOnCertChange` to run BEFORE `promoteOwnerToMaintainerOnApproval` (both mutate `workflowStatus` in the same write), which append-only ordering cannot express when the built-in always runs first. The opt-out is the escape hatch — a consumer disables the factory's copy and supplies its own fully-ordered `beforeChange` sequence. `tests/course-seams.test.ts` proves this exact case functionally. **Donor-consumer-specific invariants stay consumer-side — verified expressible, not ported.** `requireReapprovalOnCertChange`, `validateSMEInstructors` (needs that consumer's own `sme-designations` collection), the deletion-request workflow (that consumer's own reviewer/reason group), and the notify fan-outs attach via the `hooks`/`extraFields` seams, never promoted to the factory. A dedicated consumer-adoption-shape emulation test in `tests/course-seams.test.ts` proves the full shape end-to-end: `staffRelationTo: 'members'`, `instructorRoles`/`deletionRequest`/legacy `topics`/`lessons` arrays via `extraFields`, `versionsMode: 'drafts'`, the two opt-in invariants enabled, and the three consumer-specific hooks attached in that consumer's required order via the `builtInHooks` opt-out. **Coexistence: junction rows AND legacy arrays, simultaneously, on the same document.** The design doc requires the factory to tolerate a consumer carrying both the donor consumer's legacy `Course.topics`/`Course.lessons`/`Topic.course`/`Topic.lessons`/`Topic.order` arrays AND real CourseItem junction rows at once, until that consumer's own data migration retires the arrays. Both `course-seams.test.ts` and `topic-seams.test.ts` include a dedicated coexistence test: the legacy fields land as ordinary `extraFields` the factory neither reads nor writes, and — for Course — the opted-in `requirePublishedLessonsOnPublish` invariant is proven to resolve curriculum EXCLUSIVELY through CourseItem rows even when stale legacy array data is present on the same write. Nothing in either factory constrains a consumer from carrying both shapes at once. **Academy labels seam added (L-P8's one allowed touch outside Course/Topic/CourseItem — closes ledgered #361).** `AcademyCollectionConfig` gains an injectable `labels` option (default unchanged: `{ singular: 'Academy', plural: 'Academies' }`) — every other converted collection in this package already carried this seam; Academy's own L-P7 port omitted it. `tests/academy-seams.test.ts` gains two tests covering the default and the override. **Subpaths.** New `./collections/course`, `./collections/topic`, `./collections/courseItem` exports — same poisoned-barrel-alternate-door pattern as the five prior subpaths (all three collections' own module graphs were already clean: `payload` types, `../access`, `../hooks/workflow`, `./shared/*` — none touch gamification) plus their loadability tests. **Tests.** `tests/{course,topic,course-item}-characterisation.test.ts` (49 — identity, access wiring, every top-level field default, full-field-set snapshots), `tests/course-seams.test.ts` (32 — field-shape seams, versions seam, access injection, hook merge + opt-outs, both opt-in invariants' functional correctness including bypass/ordering/seam-slug tests, the consumer emulation, the coexistence proof, default-export stability), `tests/topic-seams.test.ts` (10 — polymorphic-leg seams, field pipeline, access, hooks, legacy-field coexistence), `tests/course-item-seams.test.ts` (13 — relation seams, itemType vocabulary, self-reference slug tracking, filterOptions narrowing, index rename-safety, access, hooks), `tests/{course,topic,course-item}-subpath-loadable.test.ts` (15 — built-dist loadability under raw Node, ESM+CJS, exports-map entries, symbol surfaces). `tests/academy-seams.test.ts` gains 2 (labels seam). 663 → 784 lms tests green (main's pre-port baseline was 663 passing; this branch adds 121 new tests across the files above). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 220 pass / 14 pre-existing skip / 0 fail (no new skips — the skip set is unchanged from L-P7's baseline; the 10 new files, `collections/{course,topic,courseItem}/index.{js,cjs}` and `hooks/workflow/{requirePublishedLessonsOnPublish,dropEnrollmentsOnDelete}.{js,cjs}`, all pass). Default exports-map mode: 38 pass / 4 pre-existing skip (barrel + `./server`, both intentionally poisoned doors) / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.

  • ef97756: L-P8 — Course/Topic/CourseItem convergence, the LMS convergence program's LAST pair port. Ports the field-seam discipline established by L-P3 through L-P7 onto Course, Topic, and CourseItem — the largest and last of the seven pairs, deliberately sequenced last because it carries the donor consumer's biggest migration cost (array→junction rewrite). That consumer's own `Courses`/`Topics` collections are unmodified (read-only reference). **The structural decision, reaffirmed, not re-litigated.** CourseItem junction rows are canonical course structure — this was already true pre-port (Course carried no `topics`/`lessons` arrays, CourseItem already existed) and remains true post-port. This pair's actual work was porting the field-shape/access/mergeHooks discipline onto all three collections and re-expressing the donor consumer's incident-encoded Course invariants over junction rows, not a structural migration on this package's own side. **Field seams.** `collections/{Course,Topic,CourseItem}.ts` convert from static `CollectionConfig` exports to `createCourseCollection`/`createTopicCollection`/`createCourseItemCollection`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as every prior port. All three static exports remain `create*Collection()` with every default — byte-identical wiring, pinned by `tests/{course,topic,course-item}-characterisation.test.ts` (49 tests total, committed separately BEFORE any conversion landed, verified green against both the pre-port static collections and the post-port factory output). Course gains: `staffRelationTo` (owner/createdBy/instructors/maintainers together, default `'users'` unchanged — the donor consumer targets `'members'`), `mediaRelationTo`/`academyRelationTo`/`certificationRelationTo`/`courseRelationTo`/`skillPathRelationTo` (every remaining relationship target, per design principle 3), every select vocabulary made injectable, `versionsMode: 'none' | 'drafts'` (`'drafts'` wires the donor consumer's exact `versions: { drafts: true }` plus a `readVersions` default, matching the same trap `Lesson.ts`'s `versionsMode: 'snapshot-history'` already guards against), the standard field-shape pipeline, per-verb `access`, and a `hooks` merge seam. Topic gains the three polymorphic `prerequisites[].item` leg seams (`lessonRelationTo`/`topicRelationTo`/`courseRelationTo`) plus the standard pipeline — no built-in hooks (none pre-port). CourseItem gains `courseRelationTo`/`topicRelationTo`/`lessonRelationTo`, an injectable `itemTypeOptions` vocabulary, and the standard pipeline; `parent`/`unlockAfter` now self-reference the collection's own configured `slug` rather than a hardcoded string, so a consumer's slug rename carries them automatically. CourseItem's unique/secondary indexes are field-name-based and were already rename-safe pre-port. **Org-generic invariants from the donor consumer ship as opt-in factory hooks, default OFF.** `requirePublishedLessonsOnPublish` (`hooks/workflow/requirePublishedLessonsOnPublish.ts`) re-expresses that consumer's "a course cannot publish with zero published lessons" gate over CourseItem rows instead of direct arrays — walks every lesson-type CourseItem for the course (both top-level and nested under a topic), checks `Lesson.isPublished`, blocks the transition into `status: 'published'` when the published count is zero. Update-only by construction (a create-as-published course has no CourseItem rows referencing it yet, so it always fails the check the same way the pre-port donor's own update-only gate did). `dropEnrollmentsOnDelete` (`hooks/workflow/dropEnrollmentsOnDelete.ts`) drops (not deletes) a deleted course's live enrollments, paginated with the same 50-pass/zero-progress-break guard as the donor's original. Both take injectable relation-slug options (`courseItemRelationTo`/`lessonRelationTo`/`enrollmentRelationTo`) and both share this package's unified `req.context.internal` system-bypass convention (`../cert-workflow/enforceApprovalStatus.ts`'s constant) rather than the donor's own `migrationBackfill` flag name. Enabled via `invariantHooks: { requirePublishedLessonsOnPublish: true, dropEnrollmentsOnDelete: true }` — both default `false`, so zero-config output is unaffected. **Per-built-in-hook opt-outs from birth (the L-P5.1 lesson, applied on day one).** `promoteOwnerToMaintainerOnApproval` — which already existed on Course pre-port, unconditionally wired — is now opt-outable via `builtInHooks.promoteOwnerToMaintainerOnApproval` (default `true`, byte-identical). This exists because `mergeHooks` only appends: the donor consumer's real hook order requires `requireReapprovalOnCertChange` to run BEFORE `promoteOwnerToMaintainerOnApproval` (both mutate `workflowStatus` in the same write), which append-only ordering cannot express when the built-in always runs first. The opt-out is the escape hatch — a consumer disables the factory's copy and supplies its own fully-ordered `beforeChange` sequence. `tests/course-seams.test.ts` proves this exact case functionally. **Donor-consumer-specific invariants stay consumer-side — verified expressible, not ported.** `requireReapprovalOnCertChange`, `validateSMEInstructors` (needs that consumer's own `sme-designations` collection), the deletion-request workflow (that consumer's own reviewer/reason group), and the notify fan-outs attach via the `hooks`/`extraFields` seams, never promoted to the factory. A dedicated consumer-adoption-shape emulation test in `tests/course-seams.test.ts` proves the full shape end-to-end: `staffRelationTo: 'members'`, `instructorRoles`/`deletionRequest`/legacy `topics`/`lessons` arrays via `extraFields`, `versionsMode: 'drafts'`, the two opt-in invariants enabled, and the three consumer-specific hooks attached in that consumer's required order via the `builtInHooks` opt-out. **Coexistence: junction rows AND legacy arrays, simultaneously, on the same document.** The design doc requires the factory to tolerate a consumer carrying both the donor consumer's legacy `Course.topics`/`Course.lessons`/`Topic.course`/`Topic.lessons`/`Topic.order` arrays AND real CourseItem junction rows at once, until that consumer's own data migration retires the arrays. Both `course-seams.test.ts` and `topic-seams.test.ts` include a dedicated coexistence test: the legacy fields land as ordinary `extraFields` the factory neither reads nor writes, and — for Course — the opted-in `requirePublishedLessonsOnPublish` invariant is proven to resolve curriculum EXCLUSIVELY through CourseItem rows even when stale legacy array data is present on the same write. Nothing in either factory constrains a consumer from carrying both shapes at once. **Academy labels seam added (L-P8's one allowed touch outside Course/Topic/CourseItem — closes ledgered #361).** `AcademyCollectionConfig` gains an injectable `labels` option (default unchanged: `{ singular: 'Academy', plural: 'Academies' }`) — every other converted collection in this package already carried this seam; Academy's own L-P7 port omitted it. `tests/academy-seams.test.ts` gains two tests covering the default and the override. **Subpaths.** New `./collections/course`, `./collections/topic`, `./collections/courseItem` exports — same poisoned-barrel-alternate-door pattern as the five prior subpaths (all three collections' own module graphs were already clean: `payload` types, `../access`, `../hooks/workflow`, `./shared/*` — none touch gamification) plus their loadability tests. **Tests.** `tests/{course,topic,course-item}-characterisation.test.ts` (49 — identity, access wiring, every top-level field default, full-field-set snapshots), `tests/course-seams.test.ts` (32 — field-shape seams, versions seam, access injection, hook merge + opt-outs, both opt-in invariants' functional correctness including bypass/ordering/seam-slug tests, the consumer emulation, the coexistence proof, default-export stability), `tests/topic-seams.test.ts` (10 — polymorphic-leg seams, field pipeline, access, hooks, legacy-field coexistence), `tests/course-item-seams.test.ts` (13 — relation seams, itemType vocabulary, self-reference slug tracking, filterOptions narrowing, index rename-safety, access, hooks), `tests/{course,topic,course-item}-subpath-loadable.test.ts` (15 — built-dist loadability under raw Node, ESM+CJS, exports-map entries, symbol surfaces). `tests/academy-seams.test.ts` gains 2 (labels seam). 663 → 784 lms tests green (main's pre-port baseline was 663 passing; this branch adds 121 new tests across the files above). Build clean, typecheck clean (`tsc --noEmit`, zero errors), `assert-node-loadable --every-file`: 220 pass / 14 pre-existing skip / 0 fail (no new skips — the skip set is unchanged from L-P7's baseline; the 10 new files, `collections/{course,topic,courseItem}/index.{js,cjs}` and `hooks/workflow/{requirePublishedLessonsOnPublish,dropEnrollmentsOnDelete}.{js,cjs}`, all pass). Default exports-map mode: 38 pass / 4 pre-existing skip (barrel + `./server`, both intentionally poisoned doors) / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.
v0.19.0minor

f9b479c: Fixed a defect where both CourseEnrollment progress-recompute hooks (`createRecomputeCompletedModulesProgressHook` for `completionStore: 'completed-modules'`, and `computeProgress`/`createComputeProgressHook` for the default `'lesson-completions'` mode) fired unconditionally on every `afterChange` and re-derived progress/status regardless of what the triggering write actually touched. Proven executable against real call sites at a production consumer (a status-only `{ status: 'dropped' }` write, and a bare derived-field write): the very same afterChange invocation that persisted the drop immediately re-derived a live status from the unchanged `completedModules` array and fired a corrective update reverting it — a dropped enrollment did not survive its own drop. `createRecomputeCompletedModulesProgressHook` now compares `completedModulesFieldName` between `doc` and `previousDoc` and no-ops when it did not change (a `create`, with no `previousDoc`, always derives — there is no baseline to diff against). Both hooks also gained a `preserveStatuses` option (default `['dropped']`): when the enrollment's current status is in the list, progress can still update from a genuine `completedModules`/`LessonCompletion` change, but status (and the completion-date stamp) is never overwritten. Re-activating a preserved enrollment requires the writer to set the new status explicitly — it is never a side effect of a modules/completions write. `CourseEnrollmentCollectionConfig.preserveStatuses` threads the same list to whichever hook `completionStore` selects; omitting it keeps each hook's own default and the byte-identical `computeProgress` singleton reference for zero-config `'lesson-completions'` sites.

  • f9b479c: Fixed a defect where both CourseEnrollment progress-recompute hooks (`createRecomputeCompletedModulesProgressHook` for `completionStore: 'completed-modules'`, and `computeProgress`/`createComputeProgressHook` for the default `'lesson-completions'` mode) fired unconditionally on every `afterChange` and re-derived progress/status regardless of what the triggering write actually touched. Proven executable against real call sites at a production consumer (a status-only `{ status: 'dropped' }` write, and a bare derived-field write): the very same afterChange invocation that persisted the drop immediately re-derived a live status from the unchanged `completedModules` array and fired a corrective update reverting it — a dropped enrollment did not survive its own drop. `createRecomputeCompletedModulesProgressHook` now compares `completedModulesFieldName` between `doc` and `previousDoc` and no-ops when it did not change (a `create`, with no `previousDoc`, always derives — there is no baseline to diff against). Both hooks also gained a `preserveStatuses` option (default `['dropped']`): when the enrollment's current status is in the list, progress can still update from a genuine `completedModules`/`LessonCompletion` change, but status (and the completion-date stamp) is never overwritten. Re-activating a preserved enrollment requires the writer to set the new status explicitly — it is never a side effect of a modules/completions write. `CourseEnrollmentCollectionConfig.preserveStatuses` threads the same list to whichever hook `completionStore` selects; omitting it keeps each hook's own default and the byte-identical `computeProgress` singleton reference for zero-config `'lesson-completions'` sites.
v0.18.0minor

a556cd9: Add three rename/opt-out seams to close hook-shape gaps found by consumer adapters testing against 0.17.0 (L-P5.1), all zero-config byte-identical: - `createRecomputeCompletedModulesProgressHook` (and `createCourseEnrollmentCollection`) gain `progressFieldName`/`statusFieldName`/`completionDateFieldName`/`completedModulesFieldName` options (all default to today's literal property names) — every read/write in the hook now goes through these, so a consumer that renames one of those fields via `fieldOverrides` (e.g. `overallProgress` -> `progress`) no longer gets a hook silently writing to a dead key. - `createCourseEnrollmentCollection({ completionStore: 'completed-modules' })` gains `completedModulesHooks: { autoAwardCertification?: boolean; awardCourseCompletionBadges?: boolean }` (both default `true`) — a consumer running its own gated award pipeline can now drop the factory's unconditional award hooks from the `afterChange` chain. The progress-recompute hook itself is not optional. - `createLessonCollection` gains `syncDisplayContent?: boolean` (default `true`) — a consumer whose own same-named hook does something else entirely can omit the factory's built-in from `beforeChange` instead of positionally slicing the merged array. - `createLessonCollection`, `createSkillPathCollection`, `createCertificationAwardCollection`, and `createCourseEnrollmentCollection` gain a `labels` passthrough option (each defaulting to its existing hardcoded `{ singular, plural }`) — previously only reachable by patching the constructed `CollectionConfig` object consumer-side. `createAcademyCollection` shares the same `labels` hardcode with no knob; left untouched this pass (the Academy conversion is in flight) — flagged for a follow-up.

  • a556cd9: Add three rename/opt-out seams to close hook-shape gaps found by consumer adapters testing against 0.17.0 (L-P5.1), all zero-config byte-identical: - `createRecomputeCompletedModulesProgressHook` (and `createCourseEnrollmentCollection`) gain `progressFieldName`/`statusFieldName`/`completionDateFieldName`/`completedModulesFieldName` options (all default to today's literal property names) — every read/write in the hook now goes through these, so a consumer that renames one of those fields via `fieldOverrides` (e.g. `overallProgress` -> `progress`) no longer gets a hook silently writing to a dead key. - `createCourseEnrollmentCollection({ completionStore: 'completed-modules' })` gains `completedModulesHooks: { autoAwardCertification?: boolean; awardCourseCompletionBadges?: boolean }` (both default `true`) — a consumer running its own gated award pipeline can now drop the factory's unconditional award hooks from the `afterChange` chain. The progress-recompute hook itself is not optional. - `createLessonCollection` gains `syncDisplayContent?: boolean` (default `true`) — a consumer whose own same-named hook does something else entirely can omit the factory's built-in from `beforeChange` instead of positionally slicing the merged array. - `createLessonCollection`, `createSkillPathCollection`, `createCertificationAwardCollection`, and `createCourseEnrollmentCollection` gain a `labels` passthrough option (each defaulting to its existing hardcoded `{ singular, plural }`) — previously only reachable by patching the constructed `CollectionConfig` object consumer-side. `createAcademyCollection` shares the same `labels` hardcode with no knob; left untouched this pass (the Academy conversion is in flight) — flagged for a follow-up.
  • e542ef4: L-P7 — Academy convergence, the LMS convergence program's seventh port. Ports the field-seam discipline established by L-P3/L-P4/L-P5/L-P6 onto Academy; the donor consumer's own `Academies` collection is unmodified (read-only reference). **Field seams.** `collections/Academy.ts` converts from a static `CollectionConfig` export to `createAcademyCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as the prior four ports. `AcademyCollection` remains exported as `createAcademyCollection()` with every default — byte-identical wiring for `createLmsLayer`, pinned by `tests/academy-characterisation.test.ts` (21 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output — a first pass over-eagerly added a forward `courses` array field to match the donor consumer's shape, and the pins caught it immediately as a byte-identical regression before it shipped). New seams: `mediaRelationTo` (`featuredImage`, default `'media'`), `staffRelationTo` (`directors`/`instructors`/`maintainers` together, default `'members'` — both sides already agree, the seam exists for a future divergent consumer, not to resolve one today), `categoryOptions`/`statusOptions` vocabulary overrides, `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam (merged via `mergeHooks`; the factory ships no built-in hooks — neither side had behavior hooks on the converged core set pre-port). **Small true overlap, the donor consumer's org-specifics stay on seams — neither wins.** Per the design doc, the true overlap is name/slug/staff trio/status/displayOrder — identical, and this is the one pair where MVP already targeted `members`. Everything else is that consumer's org-specifics (rank-gated visibility group, promotion/cert grants, `onboardingPhase`, unit/wing scope) vs MVP's tier-gating (`minimumTierThreshold`/`requiresOrgMembership`/`scopedOrg`) — both survive as optional field groups reachable via seams; neither is promoted to the converged default. `tests/academy-seams.test.ts` includes a dedicated consumer-adoption-shape test that emulates dropping MVP's tier-gating trio via `omitFields` and injecting `onboardingPhase`/`grantsPromotion`/`grantsCertification` via `extraFields` plus a wholesale `categoryOptions` replacement — zero factory changes needed beyond what already ships. **`category` vocabulary — replace, not extend.** MVP's four-value default (onboarding/leadership/specialist/general) and the donor consumer's five-value set (onboarding/wing/unit/leadership/specialist) disagree on `general` vs `wing`/`unit` — not a clean union. `categoryOptions` supports `{ mode: 'replace', options }` for that consumer's wholesale five-value swap, alongside `{ mode: 'extend' }` for additive cases. **Structure call: the reverse `Course.academy` relationship is canonical — MVP already implements it.** Course-scout finding worth recording precisely: MVP's `Course.ts` already carries the reverse `academy` relationship, and `Academy` itself carries NO forward `courses` array — the "reverse relationship over forward array" decision (same junction-vs-array logic as P3/P4, smaller stakes) was already satisfied pre-port; there was nothing to migrate on MVP's side. The donor consumer's OWN forward `courses` hasMany array (`Academies/index.ts:310-317`) does **NOT** come upstream — the design doc explicitly rejects it as the pattern this call replaces. That consumer's adapter carries its forward array via `extraFields` (proven reachable in `tests/academy-seams.test.ts`) until **L-P8 (Courses)** resolves structure and a migration can retire the array in favor of querying through `Course.academy`. Deferred-capability trigger: that retirement (and any read-path rewrite the consumer needs) happens when L-P8 lands Course as a factory, not in this port. **`featuredImage` upload-vs-relationship — no dedicated mechanism, same as SkillPath's icon/badge.** The donor consumer's `featuredImage` is `type: 'upload'`; this factory's default is `type: 'relationship'`. Verified reachable via `fieldOverrides: { featuredImage: { type: 'upload', relationTo: 'media' } }` — no new seam invented. **Academy.js poison status — checked, already clean.** Unlike the five gamification-adjacent siblings (`Achievement`/`Badge`/`Points`/`CourseEnrollment`/`LessonCompletion`) whose barrel-import poisoning motivated the L-P3.1/L-P4/L-P6 subpath remedies, `Academy.ts`'s own module graph has zero gamification dependency and was already loadable under raw Node pre-port (`assert-node-loadable --every-file` shows `./collections/Academy.js`/`.cjs` PASS both before and after this change) — no call-time-import edge fix was needed. **Subpath.** New `./collections/academy` export — exposes `createAcademyCollection`/`AcademyCollection`/`DEFAULT_CATEGORY_OPTIONS` without evaluating the package barrel (same poisoned-barrel remedy as the prior three subpaths, offered as an alternate door since Academy's own graph was already clean). **Tests.** `tests/academy-characterisation.test.ts` (21 — identity, access wiring, every top-level field default including the staff trio and the tier-gating/scopedOrg stub, plus a full-field-set snapshot), `tests/academy-seams.test.ts` (17 — every config seam reaches the constructed `CollectionConfig`, including the consumer-adoption-shape stand-in and the featuredImage upload-reachability proof), `tests/academy-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). 558 → 622 lms tests green (main's pre-port baseline was 558 passed / 17 skipped; this branch's 622-pass, 0-skip figure also reflects the other subpath-loadable suites resolving from their unbuilt-dist SKIP placeholder to their real dynamic pass count once `dist/` exists locally — not a regression, an artifact of running against a freshly built package). This port's own new tests: `tests/academy-characterisation.test.ts` (21), `tests/academy-seams.test.ts` (17), `tests/academy-subpath-loadable.test.ts` (5 once built). Build clean, typecheck clean, `assert-node-loadable --every-file`: 210 pass / 14 pre-existing skip / 0 fail (no new skips — both new `collections/academy` subpath dist files pass clean). Default exports-map mode: 32 pass / 4 pre-existing skip (barrel + `./server`, both intentionally poisoned doors) / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.
v0.17.0minor

eaa9d4e: L-P4 — SkillPath convergence, the LMS convergence program's second port. Ports the field-seam discipline established by L-P3's CertificationAward conversion onto SkillPath; the donor consumer's own `SpecialistPaths` collection is unmodified (read-only reference). **Field seams.** `collections/SkillPath.ts` converts from a static `CollectionConfig` export to `createSkillPathCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as `createCertificationAwardCollection`. `SkillPathCollection` remains exported as `createSkillPathCollection()` with every default — byte-identical wiring for `createLmsLayer`, pinned by `tests/skill-path-characterisation.test.ts` (21 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output). New seams: `mediaRelationTo` (icon + tiers[].badge, default `'media'`), `certificationRelationTo`/`courseRelationTo` (tiers[].requirements[], defaults `'certifications'`/`'courses'`), `statusOptions`/`requirementTypeOptions` vocabulary overrides, `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam (merged via `mergeHooks`, though the factory ships no built-in hooks — neither side had behavior hooks on this pair pre-port). **Read-access default — NOT flipped in this release.** The design doc's specified end-state is the donor consumer's `read: authenticated` posture (`publicRead` as an explicit opt-down). This port does NOT flip the zero-config default: EDU currently consumes `skill-paths` with public read and no site-side access override, so flipping the default here would be a breaking, silent behavior change for an existing zero-config consumer. `createSkillPathCollection` keeps `read: publicRead` as the 0.x default and adds the standard per-verb `access` seam — a consumer reaches the specified posture via `access: { read: authenticatedOnly }` with zero factory changes. **`@wabbit/tome-lms` 1.0.0 will flip this default to `authenticatedOnly`** per the design doc — tracked as a deliberate, deferred breaking change for the 1.0.0 cut (L-P10), not a rejection of that decision. **Relation/scoping seam — no new mechanism invented.** The design doc frames the donor consumer's `unit` (relationship → units, required, indexed) as generalizing MVP's `scopedOrg` composition stub. A required indexed relationship is just a `Field` object: that consumer's adoption is `omitFields: ['scopedOrg']` + `extraFields: [{ name: 'unit', type: 'relationship', relationTo: 'units', required: true, index: true }]` — no dedicated `scopedOrgField`-style factory option was added. `expertTierThreshold` (the donor consumer renames at adoption; its SMEDesignations collection keeps consuming the threshold through its own hook, unaffected by this port) was already a plain named field and is override-reachable today via `fieldOverrides: { expertTierThreshold: {...} }` — no new seam needed. The donor consumer's `icon`/`tiers[].badge` fields are `type: 'upload'` rather than this package's `type: 'relationship'` — a structural divergence the design doc does not call a convergence target, left as a `fieldOverrides` job for that consumer's adoption pass, not a new seam. **Subpath.** New `./collections/skillPath` export — exposes `createSkillPathCollection`/`SkillPathCollection` without evaluating the package barrel (same poisoned-barrel remedy as L-P3.1's `./collections/certificationAward`; SkillPath's own module graph has no gamification dependency and was already clean). **Tests.** `tests/skill-path-characterisation.test.ts` (21 — identity, access wiring, every top-level field default, the tiers[].requirements[] sub-schema, plus a full-field-set snapshot), `tests/skill-path-seams.test.ts` (15 — every config seam reaches the constructed `CollectionConfig`, including a stand-in proving the donor consumer's unit-scoping needs no dedicated mechanism, and the read-access seam reaching `authenticatedOnly`), `tests/skill-path-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). 420 → 461 lms tests green (41 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 192 pass / 24 pre-existing skip / 0 fail (no new skips; the two new `skillPath` subpath dist files both pass clean). Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's (per the two-double-bump incident on prior ports).

  • eaa9d4e: L-P4 — SkillPath convergence, the LMS convergence program's second port. Ports the field-seam discipline established by L-P3's CertificationAward conversion onto SkillPath; the donor consumer's own `SpecialistPaths` collection is unmodified (read-only reference). **Field seams.** `collections/SkillPath.ts` converts from a static `CollectionConfig` export to `createSkillPathCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as `createCertificationAwardCollection`. `SkillPathCollection` remains exported as `createSkillPathCollection()` with every default — byte-identical wiring for `createLmsLayer`, pinned by `tests/skill-path-characterisation.test.ts` (21 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output). New seams: `mediaRelationTo` (icon + tiers[].badge, default `'media'`), `certificationRelationTo`/`courseRelationTo` (tiers[].requirements[], defaults `'certifications'`/`'courses'`), `statusOptions`/`requirementTypeOptions` vocabulary overrides, `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam (merged via `mergeHooks`, though the factory ships no built-in hooks — neither side had behavior hooks on this pair pre-port). **Read-access default — NOT flipped in this release.** The design doc's specified end-state is the donor consumer's `read: authenticated` posture (`publicRead` as an explicit opt-down). This port does NOT flip the zero-config default: EDU currently consumes `skill-paths` with public read and no site-side access override, so flipping the default here would be a breaking, silent behavior change for an existing zero-config consumer. `createSkillPathCollection` keeps `read: publicRead` as the 0.x default and adds the standard per-verb `access` seam — a consumer reaches the specified posture via `access: { read: authenticatedOnly }` with zero factory changes. **`@wabbit/tome-lms` 1.0.0 will flip this default to `authenticatedOnly`** per the design doc — tracked as a deliberate, deferred breaking change for the 1.0.0 cut (L-P10), not a rejection of that decision. **Relation/scoping seam — no new mechanism invented.** The design doc frames the donor consumer's `unit` (relationship → units, required, indexed) as generalizing MVP's `scopedOrg` composition stub. A required indexed relationship is just a `Field` object: that consumer's adoption is `omitFields: ['scopedOrg']` + `extraFields: [{ name: 'unit', type: 'relationship', relationTo: 'units', required: true, index: true }]` — no dedicated `scopedOrgField`-style factory option was added. `expertTierThreshold` (the donor consumer renames at adoption; its SMEDesignations collection keeps consuming the threshold through its own hook, unaffected by this port) was already a plain named field and is override-reachable today via `fieldOverrides: { expertTierThreshold: {...} }` — no new seam needed. The donor consumer's `icon`/`tiers[].badge` fields are `type: 'upload'` rather than this package's `type: 'relationship'` — a structural divergence the design doc does not call a convergence target, left as a `fieldOverrides` job for that consumer's adoption pass, not a new seam. **Subpath.** New `./collections/skillPath` export — exposes `createSkillPathCollection`/`SkillPathCollection` without evaluating the package barrel (same poisoned-barrel remedy as L-P3.1's `./collections/certificationAward`; SkillPath's own module graph has no gamification dependency and was already clean). **Tests.** `tests/skill-path-characterisation.test.ts` (21 — identity, access wiring, every top-level field default, the tiers[].requirements[] sub-schema, plus a full-field-set snapshot), `tests/skill-path-seams.test.ts` (15 — every config seam reaches the constructed `CollectionConfig`, including a stand-in proving the donor consumer's unit-scoping needs no dedicated mechanism, and the read-access seam reaching `authenticatedOnly`), `tests/skill-path-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). 420 → 461 lms tests green (41 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 192 pass / 24 pre-existing skip / 0 fail (no new skips; the two new `skillPath` subpath dist files both pass clean). Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's (per the two-double-bump incident on prior ports).
  • a2048a3: L-P5 — CourseEnrollment convergence, the LMS convergence program's D-1 model swap. Ports the donor consumer's battle-tested `completedModules[]` completion model onto this package's field seams, additively; that consumer's own collections are unmodified (read-only reference). **Poison-edge fix.** `hooks/progress/awardCourseCompletionBadges.ts` and `hooks/progress/onLessonCompletion.ts` statically imported `../../utilities/gamification`, which re-exports `@wabbit/tome-gamification`'s main barrel — whose own `utilities/gamification.ts` does `import { getPointsBalance } from '../server'`, a relative import that reaches its `server-only`-guarded module unconditionally, throwing under plain Node regardless of entry point (the defect is inside `@wabbit/tome-gamification`, untouched here). Both imports are now deferred call-time `import()`s. `CourseEnrollment.js`/`.cjs`, `LessonCompletion.js`/`.cjs`, and the `hooks/progress` barrel move SKIP → PASS under `assert-node-loadable --every-file` (package-wide: 24 → 14 pre-existing skips, all now isolated to `Achievement`/`Badge`/`Points`/`server/learnerShell`/the root barrel — all inside `@wabbit/tome-gamification`'s poisoned import, out of scope). **Field seams.** `collections/CourseEnrollment.ts` converts from a static `CollectionConfig` export to `createCourseEnrollmentCollection(config)`, following the same factory + fieldShape discipline as `createCertificationAwardCollection` (L-P3). `CourseEnrollmentCollection` remains exported as `createCourseEnrollmentCollection()` with every default — byte-identical to the pre-port collection (pinned in `tests/course-enrollment-characterisation.test.ts`, written and verified green against the _unmodified_ collection before this port landed). New seams: `studentRelationTo`/`courseRelationTo`/`lessonRelationTo`; `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`; per-verb `access` override merged onto the factory's own defaults; `hooks` override APPENDED (never replacing) via `mergeHooks`. **The model swap (additive in 0.x).** New `completionStore: 'lesson-completions' | 'completed-modules'` option, default `'lesson-completions'` — unchanged: `computeProgress` still recomputes `overallProgress` from the global `LessonCompletion` collection, and `completedModules` is NOT added to the schema (that omission is what keeps the zero-config default byte-identical). `'completed-modules'` mode adds the donor consumer's `completedModules[]` array field, ported field-for-field: `moduleId`, `type` (7-value enum including `path_choice` and `ojt_signoff`, injectable via `completedModuleTypeOptions`), `pathChoiceLabel`/`pathChoiceCourse`, `submissionStatus` (default `pass`), `score`, `instructorFeedback`, `submissionReference`, `questionResults`, `completionDate`, per-module `cooldownExpiry`, `attemptCount`, `lockedOut`, `overriddenBy`/`overrideReason`, `bypassApproved`/`bypassRequest` — with relation seams for `pathChoiceCourseRelationTo`/`formSubmissionRelationTo`/`moduleOverriddenByRelationTo`/`examBypassRequestRelationTo`. The top-level MVP-only fields (`completionMode`, `accessTier`, `expiresAt`, `lastAccessDate`, enrollment-wide `cooldownExpiry`, `status: 'expired'`) stay in both modes — different granularity from the per-module cooldown, both real, no collision. **Leaf-routed recompute.** In `'completed-modules'` mode, the progress-recompute hook (`createRecomputeCompletedModulesProgressHook`, `hooks/progress/`) REPLACES `computeProgress` and routes its math through the Wave 8 `enrollmentProgress` leaf (`utilities/enrollmentProgress.ts`) instead of querying `LessonCompletion` — same never-regress-a-completion hold and zero-denominator hold the leaf already carried. No leaf signature change was needed: the upstream `recomputeEnrollmentProgress.ts` math was already a flat pass-count/total ratio (no per-module weighting), which the leaf already expresses exactly. The completable-lesson-id resolver defaults to this package's CourseItem-junction curriculum walk (`flattenCurriculumTree`) intersected with `Lesson.isPublished`, narrower than the donor consumer's three-flag (required/published/non-archived) filter since this package's `Lesson` schema doesn't yet carry the other two flags — documented as an honest divergence with a trigger condition, and overridable via `resolveCompletableLessonIds`. **Enforcement (design principle 2: "the donor consumer's model under MVP's enforcement").** Collection-level `update` access is `systemOrAdmin` in BOTH modes (unchanged from pre-port). `completedModules` additionally carries a field-level ACL (new `systemOrAdminField` export in `access/index.ts`, a `FieldAccess`-typed sibling of `systemOrAdmin`) — that consumer's `isAdminFieldLevel` convention re-expressed as this package's system-or-admin pattern, making its convention-based 8-write-site contract structural instead of conventional. A consumer's own server actions write via `overrideAccess: true` or `req.context.internal = true`, per this package's existing system-write convention. **Subpath.** `./collections/courseEnrollment` — a clean leaf subpath mirroring the L-P3.1 `certificationAward` precedent, re-exporting the factory, the static default, `DEFAULT_COMPLETED_MODULE_TYPE_OPTIONS`, and `createRecomputeCompletedModulesProgressHook`. Kept as a barrel-independent door even though `CourseEnrollment.js` itself now loads cleanly without it (the poison-edge fix was applied at its source, not routed around) — the package's root barrel is still poisoned by unrelated siblings. **Tests.** `tests/course-enrollment-characterisation.test.ts` (26, committed separately BEFORE any behavior change, verified green against both the pre-port static collection and the post-port factory's zero-config output) pins identity, access wiring, the `uniquePair` hook, `computeProgress`'s LessonCompletion-derived recompute + loop guard + completion stamp, and every field's shape. New-behavior suites: `course-enrollment-completed-modules.test.ts` (19 — field shape/relation-seam/option-override coverage for `completedModules`, the afterChange hook swap, and the leaf-routed recompute's basic correctness/never-regress/zero-denominator/loop-guard/custom-slug/error-swallow behavior) and `course-enrollment-subpath-loadable.test.ts` (6 — every built subpath file loads under raw Node, plus confirms the non-subpath `CourseEnrollment.js` door is independently clean). 420 → 471 lms tests green (51 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 204 pass / 14 pre-existing skip / 0 fail (no new skips; the known-24 shrank to 14).
  • 2284a7b: L-P6 — Lesson convergence, the LMS convergence program's fourth port. Ports the donor consumer's field-seam-reachable union fields onto this package's factory discipline, additively; that consumer's own `Lessons` collection and its site-specific access implementations are unmodified (read-only reference). **Factory conversion.** `collections/Lesson.ts` converts from a static `CollectionConfig` export to `createLessonCollection(config)`, using the same `./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` mechanism as `createCertificationAwardCollection` (L-P3) and `createSkillPathCollection` (L-P4). `LessonCollection` remains exported as `createLessonCollection()` with every default, pinned by `tests/lesson-characterisation.test.ts` (31 tests, committed separately BEFORE the conversion, verified green against both the pre-port static collection and the post-port factory output) — with one deliberate, documented exception: the `lessonType` default vocabulary. New seams: `formCollectionRelationTo` (`quizForm`/`assignmentForm` + draft counterparts, default `'lessons'` self-reference pending D-2b), `sourceDocumentsRelationTo` (default `'media'`), `reviewedByRelationTo` (default `'users'`), `certificationRelationTo`/`courseRelationTo`/`topicRelationTo`/`lessonRelationTo` (default this package's own slugs), `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`, per-verb `access` override, and a `hooks` seam merged via `mergeHooks`. **Enum-union default (breaking-in-shape, not breaking-in-behavior).** `lessonTypeOptions` is now injectable, defaulting to the UNION of MVP's six values and the donor consumer's `path_choice` — the design doc's explicit call ("MVP's enum is missing an option the donor consumer load-bears on"). Unlike every other L-P3/L-P4/L-P5 default, this one is NOT byte-identical to pre-port: the zero-config `lessonType` field now offers seven options where it offered six. `tests/lesson-type.test.ts`'s pre-existing closed-set pin was updated in this port to include `path_choice` (documented inline as the one intentional exception). `pathOptions`/`draftPathOptions` (the donor consumer's "Choose Your Path" fields) join the field set only when `path_choice` is present in the resolved vocabulary — a consumer that replaces the vocabulary and excludes `path_choice` gets no dangling path-choice UI. `requiredCertification` and `instructorNotes` (from the donor consumer) join UNCONDITIONALLY — both optional/harmless-when-unused, per the design doc's explicit call not to gate them. **Field-level access — no new mechanism invented.** The design doc asks that content-body fields' access be injectable so the donor consumer's `canReadLessonBody`/`canReadLessonInstructorContent` gates are expressible. L-P4 already established that the shared `fieldOverrides` seam (post-construction, per-field, merges `access` one level deep) is sufficient for this without a bespoke option — `fieldOverrides: { content: { access: { read: canReadLessonBody } }, instructorNotes: { access: { read: canReadLessonInstructorContent } } }` reaches every body field this way. No field on this collection carries a field-level access default pre- or post-port (byte-identical: nothing was gated before, nothing is gated now, unless a consumer opts in). **`workflowStatus` — folded (0.x default) vs orthogonal.** New `publishStateMode: 'folded' | 'orthogonal'` option, default `'folded'` — the current 5-value enum (`draft/pending_review/changes_requested/approved/published`) is UNCHANGED, and `workflowStatusOptions` is independently injectable on top of it. `'orthogonal'` swaps to the donor consumer's 4-value enum (`'archived'` replaces `'published'`) and decouples `isPublished` from the enum entirely — the design doc's specified 1.0.0 end-state ("MVP's 5th `published` value conflates workflow with publish state and loses the distinction the donor consumer's invariant hooks depend on"). **`@wabbit/tome-lms` 1.0.0 will flip `publishStateMode`'s default to `'orthogonal'`** — the same deferred-breaking-change pattern as SkillPath's read-access default flip (L-P4) and CourseEnrollment's model default (L-P5), tracked here, not executed in this port. `syncDisplayContent`'s publish TRIGGER is mode-dependent, since orthogonal mode has no `'published'` value to key off: folded mode keeps the byte-identical `workflowStatus === 'published'` trigger; orthogonal mode triggers on `isPublished` transitioning to `true` in the same write. This is an honest ADAPTATION, not a literal port — the donor consumer's real publish path is `syncDraftToLive()`, an imperative function called by a site-layer publish action, not a collection hook; porting that action is out of scope for a collection-factory port. That consumer's `enforceArchiveInvariant`/`enforcePendingReviewInvariant` cross-field invariants stay consumer-side, attached via the `hooks` merge seam — they encode its own terminal-state policy on top of the converged schema, not a converged-schema requirement. **`versions` — none (default) vs snapshot-history.** New `versionsMode: 'none' | 'snapshot-history'` option, default `'none'` (current — Payload versions stay off, unchanged). `'snapshot-history'` emits the donor consumer's hand-rolled history posture verbatim: `versions: { drafts: false, maxPerDoc: 25 }` (constant `LESSON_VERSIONS_MAX_PER_DOC`, that consumer's production-measurement rationale comment ported forward in full — see the constant's header), tunable per-site via `versionsMaxPerDoc`. `drafts: false` stays load-bearing for the same reason on this side of the fork: Lesson already runs its own hand-rolled draft rail on the `draft*` shadow fields, and Payload drafts would stand up a second, competing draft concept. When `versionsMode: 'snapshot-history'`, a `readVersions` access default (`instructorOrHigher`) is wired — Payload resolves an undefined `readVersions` to "any authenticated user" once versions are enabled, a trap the donor consumer's own code comments call out — independently injectable via `access.readVersions` so its `canReadLessonVersions` reaches it without a factory change. **Access — public read kept as the 0.x default.** Every verb (`read`/`create`/`update`/`delete`/`readVersions`) is injectable via `config.access`, merged on top of the factory's current defaults (`lessonRead` — public — for read, `instructorOrHigher` for create/update, `adminOnly` for delete). The design doc's specified end-state is the donor consumer's layered row+field posture (`canReadLesson` + `canReadLessonBody`/`canReadLessonInstructorContent`) as the factory DEFAULT; this port does NOT flip it — that consumer's access implementations are deeply site-specific (its own course-staffing/enrollment resolvers) and stay consumer-side entirely, reachable today via `access: { read: canReadLesson }` + the `fieldOverrides` seam with zero factory changes. **`@wabbit/tome-lms` 1.0.0 will flip the `read` default to a layered posture** — tracked here for the 1.0.0 cut, not executed in this port. `createLmsLayer`'s existing `lessonReadAccess: 'enrollment-gated'` registration-time rewrite (`applyLessonReadAccess`) is unaffected — it still finds a `lessons`-slug collection with a `read` key to swap. **Course/topic/order fields do NOT join the converged set.** The donor consumer's direct `Lesson.course`/`Lesson.topic`/`Lesson.order` fields are superseded by the already-settled P3 design call (CourseItem junction as canonical course structure) — Lesson stays course-agnostic both pre- and post-port; course membership lives on CourseItem rows. **Subpath.** New `./collections/lesson` export — exposes `createLessonCollection`/`LessonCollection` plus the option-vocabulary constants without evaluating the package barrel (same poisoned-barrel remedy as L-P3.1's `certificationAward` and L-P4's `skillPath` subpaths; Lesson's own module graph has no gamification dependency and was already clean). **Tests.** `tests/lesson-characterisation.test.ts` (31 — identity, access wiring incl. public read, hook wiring, dual-track shadow-field pairing, relationship targets, compass/visibility fields, and `syncDisplayContent`'s publish-transition behavior; committed separately before the conversion, verified green pre- and post-port), `tests/lesson-seams.test.ts` (31 — the enum-union default and its gated `pathOptions`, `requiredCertification`/`instructorNotes` unconditional joins, field-level access via `fieldOverrides`, every relation-target seam, per-verb access injection, `publishStateMode: 'orthogonal'`'s 4-value enum + decoupled publish trigger, `versionsMode: 'snapshot-history'`'s config + `readVersions` default, and hook-array merging), `tests/lesson-subpath-loadable.test.ts` (5 — built-dist loadability under raw Node, ESM+CJS, exports-map entry, symbol surface). `tests/lesson-type.test.ts`'s pre-existing closed-set pin was updated (documented inline) for the one intentional default change. 420 → 487 lms tests green (67 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 192 pass / 24 pre-existing skip / 0 fail (no new skips; the two new `lesson` subpath dist files both pass clean). Default exports-map mode: 26 pass / 4 pre-existing skip / 0 fail. `LMS_LAYER_VERSION` and `package.json` version are NOT bumped in this changeset — versioning is the release process's job, not the builder's.
v0.16.0minor

99889d1: Three additive seams for the consumer cert-adoption leg (L-P3.1): - New `./collections/certificationAward` subpath — exposes `createCertificationAwardCollection`, `CertificationAwardCollection`, both cert-award context-flag constants, and every cert-workflow hook factory, without evaluating the package barrel. The barrel transitively evaluates `server-only` (via the `Achievement`/`Badge`/`Points`/`CourseEnrollment`/`LessonCompletion` re-exports reaching `@wabbit/tome-gamification`'s own poisoned-barrel leaf), which throws under plain Node/tsx — killing `payload generate:types` and any other config-graph tooling that needs CertificationAward without a react-server condition. `CertificationAward`'s own module graph was already clean; this subpath is the door that proves it and keeps it that way. - `calculateExpiryDate` / `createCalculateExpiryDate` gain a `validityPath` option — a dot-path prefix (e.g. `'validity'`) resolving `expiresField`/`validityPeriodField` off a nested object instead of the certification doc's top level, for a consumer whose Certification schema nests them under `validity.{expires,validityPeriod}`. Unset (default) is byte-identical to the pre-existing flat-field behavior; an unresolvable path is a safe no-op (treated as non-expiring), never a throw. - `createUpdateMemberCerts` / `createAfterDeleteMemberCerts` gain an `onSynced(memberId, req)` callback, invoked once a member-cert sync succeeds (never on failure, alongside — not instead of — `onSyncFailure`). Lets a consumer bust its own cache (or run any other success-side effect) after the roster denormalization writes, without forking the hook.

  • 99889d1: Three additive seams for the consumer cert-adoption leg (L-P3.1): - New `./collections/certificationAward` subpath — exposes `createCertificationAwardCollection`, `CertificationAwardCollection`, both cert-award context-flag constants, and every cert-workflow hook factory, without evaluating the package barrel. The barrel transitively evaluates `server-only` (via the `Achievement`/`Badge`/`Points`/`CourseEnrollment`/`LessonCompletion` re-exports reaching `@wabbit/tome-gamification`'s own poisoned-barrel leaf), which throws under plain Node/tsx — killing `payload generate:types` and any other config-graph tooling that needs CertificationAward without a react-server condition. `CertificationAward`'s own module graph was already clean; this subpath is the door that proves it and keeps it that way. - `calculateExpiryDate` / `createCalculateExpiryDate` gain a `validityPath` option — a dot-path prefix (e.g. `'validity'`) resolving `expiresField`/`validityPeriodField` off a nested object instead of the certification doc's top level, for a consumer whose Certification schema nests them under `validity.{expires,validityPeriod}`. Unset (default) is byte-identical to the pre-existing flat-field behavior; an unresolvable path is a safe no-op (treated as non-expiring), never a throw. - `createUpdateMemberCerts` / `createAfterDeleteMemberCerts` gain an `onSynced(memberId, req)` callback, invoked once a member-cert sync succeeds (never on failure, alongside — not instead of — `onSyncFailure`). Lets a consumer bust its own cache (or run any other success-side effect) after the roster denormalization writes, without forking the hook.
v0.15.0minor

d0e5b36: L-P3 — CertificationAward convergence, the LMS convergence program's first port. Ports the donor consumer's battle-tested CertificationAwards behavior onto this package's field seams; that consumer's own collections are unmodified (read-only reference). **Field seams.** `collections/CertificationAward.ts` converts from a static `CollectionConfig` export to `createCertificationAwardCollection(config)`, following the org layer's factory + fieldShape discipline (`./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` — local ports of `@wabbit/tome-org`'s mechanism, not a cross-package dependency, since LMS treats org as an optional composition peer). `CertificationAwardCollection` remains exported as `createCertificationAwardCollection()` with every default — byte-identical wiring for `createLmsLayer`. New seams: `defaultApprovalStatus` (factory default stays `'pending_approval'`); `awardingMethodOptions` (default: the UNION of MVP's and the donor consumer's vocabularies — course_completion, academy_completion, manual_grant, legacy_import, founding_instructor, exam_pass); `recipientRelationTo`/`awardedByRelationTo`/`revokedByRelationTo`/`certificationRelationTo`/`courseEnrollmentRelationTo`/`courseRelationTo`/`trainingEventRelationTo` (awardedBy defaults `'users'`; the donor consumer points it at `'members'`); `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`; per-verb `access` override merged onto the factory's own MVP-tier defaults; `hooks` override APPENDED (never replacing) via `mergeHooks`. `awardDate` is now REQUIRED (the donor consumer's call — an award without a date is a data bug). MVP's `status` + revocation trio (`revokedBy`/`revokedAt`/`revocationReason`) is kept — that consumer gains a revocation model it lacked. **Behavior ports.** - `updateCertificationHolderCount` (afterChange AND afterDelete — the afterDelete hook did not exist before) REPLACES the ±1 row-delta with the donor consumer's distinct-recipient full recount: paginated `payload.find` (never `limit: 0`), counting DISTINCT recipients not award rows. Incident rationale ported verbatim in the hook's header (a certification read 822 against 774 actual holders — the ±1 delta reproduces this class of bug the moment any recipient holds more than one award row for the same certification, e.g. a retake or a legacy import alongside a later completion). Failure handling does NOT import Sentry — an injectable `onSyncFailure` callback is the seam a consumer wires to its own error tracking. - `updateMemberCerts` REPLACES the debug-log stub with the donor consumer's real re-derivation (full effective-award-set recompute, not a delta), shipped as an OPT-IN factory (`memberCertSync`, default `false` — a site whose Member collection lacks `certificationAwards`/`certifications` array fields pays nothing). Honors a `reconcilerPass` context-flag bypass (`CERT_AWARD_RECONCILER_PASS_CONTEXT_FLAG`, afterChange only — matches the reference implementation, since a delete is never part of a bulk-insert storm) so a migration/reconciliation script can batch its own sync instead of triggering one write per inserted row. Same no-Sentry `onSyncFailure` seam. - `calculateExpiryDate` is now a REAL beforeChange hook — the `expiresAt` field comment claimed this behavior since before this package existed as a factory; nothing implemented it. Adapted to this package's own Certification schema (flat `expires`/`validityPeriod`, not the donor consumer's nested `validity` group) with a field-name seam for a consumer whose shape diverges further. - `enforceApprovalStatus` merges trivially (same logic both sides); the bypass context-flag names unify onto ONE factory-exported constant (`CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG = 'internal'`, this package's pre-existing single-flag convention — the donor consumer's two flags, `systemAutoAward`/`migrationBackfill`, converge onto it at that consumer's adoption pass). Create-only semantics unchanged. - `autoNominateExpert` stays wired unconditionally (MVP-only, already a safe no-op absent a matching SkillPath). - The donor consumer's own fan-outs (notifyCertApproval, cancelObsoleteExamTickets, resolveRenewalTasks, SME nomination) do NOT come upstream — a consumer appends them via `config.hooks`, verified to merge rather than replace. **Tests.** `tests/cert-award-characterisation.test.ts` (17 tests, committed separately BEFORE any behavior change, verified green against both the pre-port static collection and the post-port factory output) pins what's kept: access wiring, relationship-target defaults, the status+revocation trio, approvalStatus/renewalStatus defaults, enforceApprovalStatus's create-only gate. New-behavior suites: `cert-holder-count-recount.test.ts` (12 — multi-row-per-recipient recount correctness, afterDelete recount, pagination/truncation, failure-callback isolation), `cert-member-sync.test.ts` (9 — full re-derivation, reconcilerPass bypass scoped to afterChange only, field-name seam), `cert-expiry-calc.test.ts` (12 — computation, idempotency guard, schema seam), `cert-award-seams.test.ts` (19 — every config option reaches the constructed `CollectionConfig`, including a stand-in proving a wing/unit-scoped instructor `access.update` override is reachable with ZERO factory changes, and that hook-array merging appends rather than replaces). 335 → 404 lms tests green (69 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 188 pass / 24 pre-existing skip / 0 fail (no new skips). `LMS_LAYER_VERSION` bumped to 0.15.0 in the same commit as this changeset, per the layer-version pin test.

  • d0e5b36: L-P3 — CertificationAward convergence, the LMS convergence program's first port. Ports the donor consumer's battle-tested CertificationAwards behavior onto this package's field seams; that consumer's own collections are unmodified (read-only reference). **Field seams.** `collections/CertificationAward.ts` converts from a static `CollectionConfig` export to `createCertificationAwardCollection(config)`, following the org layer's factory + fieldShape discipline (`./collections/shared/{fieldShape,optionOverrides,mergeHooks}.ts` — local ports of `@wabbit/tome-org`'s mechanism, not a cross-package dependency, since LMS treats org as an optional composition peer). `CertificationAwardCollection` remains exported as `createCertificationAwardCollection()` with every default — byte-identical wiring for `createLmsLayer`. New seams: `defaultApprovalStatus` (factory default stays `'pending_approval'`); `awardingMethodOptions` (default: the UNION of MVP's and the donor consumer's vocabularies — course_completion, academy_completion, manual_grant, legacy_import, founding_instructor, exam_pass); `recipientRelationTo`/`awardedByRelationTo`/`revokedByRelationTo`/`certificationRelationTo`/`courseEnrollmentRelationTo`/`courseRelationTo`/`trainingEventRelationTo` (awardedBy defaults `'users'`; the donor consumer points it at `'members'`); `extraFields`/`extraFieldsAfter`/`fieldOverrides`/`omitFields`/`fieldOrder`; per-verb `access` override merged onto the factory's own MVP-tier defaults; `hooks` override APPENDED (never replacing) via `mergeHooks`. `awardDate` is now REQUIRED (the donor consumer's call — an award without a date is a data bug). MVP's `status` + revocation trio (`revokedBy`/`revokedAt`/`revocationReason`) is kept — that consumer gains a revocation model it lacked. **Behavior ports.** - `updateCertificationHolderCount` (afterChange AND afterDelete — the afterDelete hook did not exist before) REPLACES the ±1 row-delta with the donor consumer's distinct-recipient full recount: paginated `payload.find` (never `limit: 0`), counting DISTINCT recipients not award rows. Incident rationale ported verbatim in the hook's header (a certification read 822 against 774 actual holders — the ±1 delta reproduces this class of bug the moment any recipient holds more than one award row for the same certification, e.g. a retake or a legacy import alongside a later completion). Failure handling does NOT import Sentry — an injectable `onSyncFailure` callback is the seam a consumer wires to its own error tracking. - `updateMemberCerts` REPLACES the debug-log stub with the donor consumer's real re-derivation (full effective-award-set recompute, not a delta), shipped as an OPT-IN factory (`memberCertSync`, default `false` — a site whose Member collection lacks `certificationAwards`/`certifications` array fields pays nothing). Honors a `reconcilerPass` context-flag bypass (`CERT_AWARD_RECONCILER_PASS_CONTEXT_FLAG`, afterChange only — matches the reference implementation, since a delete is never part of a bulk-insert storm) so a migration/reconciliation script can batch its own sync instead of triggering one write per inserted row. Same no-Sentry `onSyncFailure` seam. - `calculateExpiryDate` is now a REAL beforeChange hook — the `expiresAt` field comment claimed this behavior since before this package existed as a factory; nothing implemented it. Adapted to this package's own Certification schema (flat `expires`/`validityPeriod`, not the donor consumer's nested `validity` group) with a field-name seam for a consumer whose shape diverges further. - `enforceApprovalStatus` merges trivially (same logic both sides); the bypass context-flag names unify onto ONE factory-exported constant (`CERT_AWARD_SYSTEM_BYPASS_CONTEXT_FLAG = 'internal'`, this package's pre-existing single-flag convention — the donor consumer's two flags, `systemAutoAward`/`migrationBackfill`, converge onto it at that consumer's adoption pass). Create-only semantics unchanged. - `autoNominateExpert` stays wired unconditionally (MVP-only, already a safe no-op absent a matching SkillPath). - The donor consumer's own fan-outs (notifyCertApproval, cancelObsoleteExamTickets, resolveRenewalTasks, SME nomination) do NOT come upstream — a consumer appends them via `config.hooks`, verified to merge rather than replace. **Tests.** `tests/cert-award-characterisation.test.ts` (17 tests, committed separately BEFORE any behavior change, verified green against both the pre-port static collection and the post-port factory output) pins what's kept: access wiring, relationship-target defaults, the status+revocation trio, approvalStatus/renewalStatus defaults, enforceApprovalStatus's create-only gate. New-behavior suites: `cert-holder-count-recount.test.ts` (12 — multi-row-per-recipient recount correctness, afterDelete recount, pagination/truncation, failure-callback isolation), `cert-member-sync.test.ts` (9 — full re-derivation, reconcilerPass bypass scoped to afterChange only, field-name seam), `cert-expiry-calc.test.ts` (12 — computation, idempotency guard, schema seam), `cert-award-seams.test.ts` (19 — every config option reaches the constructed `CollectionConfig`, including a stand-in proving a wing/unit-scoped instructor `access.update` override is reachable with ZERO factory changes, and that hook-array merging appends rather than replaces). 335 → 404 lms tests green (69 new). Build clean, typecheck clean, `assert-node-loadable --every-file`: 188 pass / 24 pre-existing skip / 0 fail (no new skips). `LMS_LAYER_VERSION` bumped to 0.15.0 in the same commit as this changeset, per the layer-version pin test.
v0.14.0minor

57b7a43: Wave 8 I1 — new `./utilities/enrollmentProgress` leaf subpath: `computeEnrollmentProgressFields(completableLessonIds, completedModules, stored)`, a pure, synchronous recompute of a course enrollment's `progress` (%) and `status` from a `completedModules` array against a caller-resolved completable-lesson-id set. Ported from a consumer's single-writer helper that three separate mutators (quiz auto-grade, instructor grading, an inbox grading action, plus seven more sites since ground-truthed) share so `completedModules`, `progress`, and `status` can never drift apart. This leaf keeps only the pure derivation — the curriculum lookup that resolves the completable set (a Payload query intersecting curriculum ∩ required/published/non-archived lessons) stays consumer-side; callers pass in the resolved `Set<string>`. Two incident-driven guards port verbatim, with their incident-citing comments generalized off consumer-internal names but keeping the mechanism: - **zero-denominator hold** — an empty completable set returns the stored `progress`/`status` unchanged (`held: 'zero-denominator'`), never recomputes to 0%/`'enrolled'`. Prevents a course whose curriculum goes fully unpublished from silently erasing a finished enrollment. - **completion-never-regresses** — once `stored.status === 'completed'`, a recompute that would drop below `'completed'` (e.g. the course gained a required lesson after the student finished) holds status at `'completed'` and returns `progress: Math.max(stored, recomputed)` — it can rise, never fall. Prevents a completed enrollment from reopening itself when the curriculum grows. Exported ONLY via the new `./utilities/enrollmentProgress` subpath plus the root barrel (mirrors the existing `./utilities/awardStatus` leaf pattern exactly — see the 0.13.1 changelog entry on the poisoned-barrel lesson: the root barrel still evaluates `server-only` transitively via the gamification re-exports, so consumers outside a react-server context should keep importing the leaf subpath directly). Zero Payload import, zero I/O, zero side effects — `sideEffects: false` already covers it. Distinct from this package's existing `./utilities/progress` (`computeProgress`/`getCompletedLessonIds`), which derives progress from tome-lms's native `LessonCompletion` collection for consumers whose enrollment does not denormalize a `completedModules` array. Pick the model matching your enrollment shape. Additive-only: new file (`src/utilities/enrollmentProgress.ts`), new exports-map entry, new root-barrel re-export, new test file (`tests/enrollment-progress.test.ts`, 20 tests — 12 ported 1:1 from the consumer's pinned characterization suite plus 8 new edge cases: null/undefined `completedModules`, non-string `moduleId`, invalid stored status, out-of-range stored progress clamping, `'dropped'` status handling on both guard paths, and a purity/idempotency check). Zero existing files' behavior changed.

  • 57b7a43: Wave 8 I1 — new `./utilities/enrollmentProgress` leaf subpath: `computeEnrollmentProgressFields(completableLessonIds, completedModules, stored)`, a pure, synchronous recompute of a course enrollment's `progress` (%) and `status` from a `completedModules` array against a caller-resolved completable-lesson-id set. Ported from a consumer's single-writer helper that three separate mutators (quiz auto-grade, instructor grading, an inbox grading action, plus seven more sites since ground-truthed) share so `completedModules`, `progress`, and `status` can never drift apart. This leaf keeps only the pure derivation — the curriculum lookup that resolves the completable set (a Payload query intersecting curriculum ∩ required/published/non-archived lessons) stays consumer-side; callers pass in the resolved `Set<string>`. Two incident-driven guards port verbatim, with their incident-citing comments generalized off consumer-internal names but keeping the mechanism: - **zero-denominator hold** — an empty completable set returns the stored `progress`/`status` unchanged (`held: 'zero-denominator'`), never recomputes to 0%/`'enrolled'`. Prevents a course whose curriculum goes fully unpublished from silently erasing a finished enrollment. - **completion-never-regresses** — once `stored.status === 'completed'`, a recompute that would drop below `'completed'` (e.g. the course gained a required lesson after the student finished) holds status at `'completed'` and returns `progress: Math.max(stored, recomputed)` — it can rise, never fall. Prevents a completed enrollment from reopening itself when the curriculum grows. Exported ONLY via the new `./utilities/enrollmentProgress` subpath plus the root barrel (mirrors the existing `./utilities/awardStatus` leaf pattern exactly — see the 0.13.1 changelog entry on the poisoned-barrel lesson: the root barrel still evaluates `server-only` transitively via the gamification re-exports, so consumers outside a react-server context should keep importing the leaf subpath directly). Zero Payload import, zero I/O, zero side effects — `sideEffects: false` already covers it. Distinct from this package's existing `./utilities/progress` (`computeProgress`/`getCompletedLessonIds`), which derives progress from tome-lms's native `LessonCompletion` collection for consumers whose enrollment does not denormalize a `completedModules` array. Pick the model matching your enrollment shape. Additive-only: new file (`src/utilities/enrollmentProgress.ts`), new exports-map entry, new root-barrel re-export, new test file (`tests/enrollment-progress.test.ts`, 20 tests — 12 ported 1:1 from the consumer's pinned characterization suite plus 8 new edge cases: null/undefined `completedModules`, non-string `moduleId`, invalid stored status, out-of-range stored progress clamping, `'dropped'` status handling on both guard paths, and a purity/idempotency check). Zero existing files' behavior changed.
  • fa9c30b: Wave 8 I2 — new `./utilities/attemptPolicy` leaf subpath: `resolveAttemptPolicy(progressionRules?)` and `applyAttemptOutcome(prev, outcome, policy, now)`, a pure derivation of a graded module's attempt/cooldown/lockout state on `CourseEnrollment.completedModules[]`. Ported from the same consumer's `syncTrainingToEnrollment.ts` EventAttendance afterChange hook (the sibling I1 leaf's neighbor step, ~lines 203-250, pinned in that repo's `tests/unit/lms-charact/syncTrainingCooldown.spec.ts`) that builds the upserted `completedModules` row on a training outcome. `resolveAttemptPolicy` reads `{cooldownHours, maxAttempts}` off a course's `progressionRules`, defaulting to 48/3 via nullish coalescing (not falsy coalescing — `0` and negative values are honored verbatim, mirroring the source's lack of validation). `applyAttemptOutcome` takes the previous `{attempts, lockedOut, cooldownExpiry}`, a `'passed' | 'failed'` outcome, the resolved policy, and a caller-supplied clock reading, and returns the next state. Four source oddities are preserved rather than fixed, documented in the file header and exercised in the test suite: (1) `attempts` increments on every graded outcome, pass or fail — it counts attempts, not failures; (2) `0`/negative `cooldownHours`/`maxAttempts` pass through unvalidated (a `maxAttempts: 0` course locks out on the first fail); (3) a below-cap FAIL leaves a prior `lockedOut` value untouched rather than clearing it; (4) a PASS unconditionally clears both `lockedOut` and `cooldownExpiry`, releasing even a prior lockout (the documented instructor-override-then-pass path). A grep across the reference consumer found this logic duplicated with varying fidelity at four more `completedModules` writers, inventoried in the Wave 8 I2 build report (not absorbed here — this leaf only ports the canonical shape so a future absorption pass has one place to delegate to): an OJT sign-off action and an out-of-band grading route both reuse the increment-and-clear-on-pass shape without the fail/cooldown branch (they only ever pass); an exam-waiver action explicitly leaves `attempts` untouched with a comment that a waiver is not an attempt; and an override-module-lock helper clears `lockedOut`/`cooldownExpiry` as part of an admin override without touching `attempts` at all. Two more sites (a challenge-mode gate and an exam-ticket gate) read `cooldownExpiry`/`lockedOut`/`maxAttempts` to gate an action but do not write these fields, and diverge from this policy's default: the challenge-mode gate has no `?? 3` fallback, so an unset `maxAttempts` gates on `lockedOut` alone. Exported ONLY via the new `./utilities/attemptPolicy` subpath plus the root barrel (mirrors the `./utilities/awardStatus` and `./utilities/enrollmentProgress` leaf pattern — see the I1 changeset on the poisoned-barrel lesson). Zero Payload import, zero I/O, zero side effects. Additive-only: new file (`src/utilities/attemptPolicy.ts`), new exports-map entry, new root-barrel re-export, new test file (`tests/attempt-policy.test.ts`, ported-behavior pins plus cap-boundary, already-locked-re-fail, pass-after-lock, and zero/negative-rule-value edge cases). Zero existing files' behavior changed.
  • fc30bf6: Wave 8 I3/I4 — two new pure-utility leaf subpaths. `./utilities/gradingCalibration` (I3): `gradeMultiSelect(selected, correct, mode?)`, a "select all that apply" scorer ported from a reference consumer's `utilities/academy/gradeMultiSelect.ts` — supports `'all_or_nothing'` (default) and `'partial'` modes, the latter charging `(correctPicked - incorrectPicked) / totalCorrect` (floored at 0) specifically to close the "tick every box" exploit naive partial credit allows. A distinct question-type contract from this package's existing `scoreQuizAnswers`'s `'multiple-choice'` case (exact-set match only) — the two are not interchangeable. Plus four calibration-audit deviation functions ported from that consumer's `data/academy/calibrationSnapshot.ts` and `features/academy/calibration/actions/submitCalibrationGrade.ts`: `classifyCalibrationDeviation` (tri-state clear/watch/flag, thresholds now overridable, defaulting to the source's 7/10), `summarizeCalibrationDeviations` (per-instructor count/average/status rollup from an already-grouped deviation array), `classifyCalibrationAuditOutcome` (concordant/discordant — note its `>` boundary deliberately differs from `classifyCalibrationDeviation`'s `>=` flag boundary, preserved not fixed), and `computeCalibrationDeviation` (the organizer-vs-co-host grade comparison across three supported payload shapes: `{score}`, `{criteria: {...}}`, flat numeric map). The consumer's Payload-querying calibration aggregator (`getCalibrationSnapshot`) is NOT ported — no cohesive pure core beyond the pieces above; that stays consumer-side. `./utilities/heldCertifications` (I4): `isHeldAwardStatus` and `heldCertKey`, extending the `./utilities/awardStatus` family (new sibling file, not an edit to `awardStatus.ts`) with the WIDER "does this member hold this certification" tolerance a production audit required upstream — an explicit empty-string `approvalStatus` (a manually-granted/legacy-imported award row written that way rather than left unset) also counts as held, alongside the existing approved/null/undefined agreement with `isEffectiveAwardStatus`. Most of the I4 candidate surface (`certAwardGate.ts`'s dedup-key builders, `resolveRelationId`, the linked-course gate, the idempotent award-create) was found ALREADY PRESENT in this package's own `src/server/awardGate.ts` (a prior wave's native port) and is not duplicated here. `effectiveCompletion.ts` was evaluated and NOT ported — every exported function is Payload-I/O end to end with no isolable pure math, only trivial relation-id/empty-shape helpers duplicated by every other file in this family. Audit finding (documented in the new file's header, not acted on — no pre-existing file changed): this package's own internal held-checks already disagree with each other the way the source consumer's three surfaces did. `hooks/cert-workflow/autoNominateExpert.ts`'s local `memberHoldsCertification` filters `approvalStatus: { equals: 'approved' }` only (stricter than `EFFECTIVE_AWARD_STATUSES`, excludes legacy-null); `server/jobs/reconcileCourseCompletionAwards.ts`'s inline `certKey` uses a single-colon separator, not this leaf's `::`. Flagged for a follow-up convergence pass, not fixed here. `utilities/academy/deriveEnrollmentProgress.ts` was evaluated for I3 and NOT ported: its exported function does Payload I/O directly (a `getCompletableLessonIdsMap` query) with no pure top-level entry point, and its one pure fragment (`countsAsComplete`, a pass-or-no-status predicate) is a numerator convention already covered by the Wave 8 I1 `./utilities/enrollmentProgress` leaf's completedModules handling — not identical, but not cohesive enough on its own to justify a separate export. Both leaves are exported ONLY via their new subpaths plus the root barrel (mirrors the `./utilities/awardStatus` / `./utilities/enrollmentProgress` / `./utilities/attemptPolicy` leaf pattern). Zero Payload import, zero I/O, zero side effects in either leaf. Additive-only: two new files (`src/utilities/gradingCalibration.ts`, `src/utilities/heldCertifications.ts`), two new test files (`tests/grading-calibration.test.ts` — 40 tests, 18 ported 1:1 from the reference consumer's `gradeMultiSelect.spec.ts` confirmed green there first, 22 new for the calibration functions and the boundary/threshold-override edge cases; `tests/held-certifications.test.ts` — 12 tests, written fresh from the source doc comment's contract, no dedicated upstream spec existed), append-only exports-map entries, append-only root-barrel re-exports. 293/293 lms tests green (was 241). Build clean, typecheck clean, every-file guard 0 fail (176 pass / 24 pre-existing skip), standalone ESM+CJS import verified for both new subpaths post-build. Zero existing files' behavior changed.
v0.13.1patch

a644dc6: Leaf subpath exports for the pure utilities (awardStatus, prerequisites, instructorRoles, ownerAuthority, certRenewalKeys, version). The root barrel evaluates `server-only` (transitively via the gamification re-exports), which throws under plain Node and tsx — killing any consumer that imports a pure predicate inside its payload-config graph (payload generate:types runs under tsx). Same poisoned-barrel class as core's /auth; same remedy: import the leaf, never the barrel, when outside a react-server context. All six subpaths PASS assert-node-loadable in both conditions.

  • a644dc6: Leaf subpath exports for the pure utilities (awardStatus, prerequisites, instructorRoles, ownerAuthority, certRenewalKeys, version). The root barrel evaluates `server-only` (transitively via the gamification re-exports), which throws under plain Node and tsx — killing any consumer that imports a pure predicate inside its payload-config graph (payload generate:types runs under tsx). Same poisoned-barrel class as core's /auth; same remedy: import the leaf, never the barrel, when outside a react-server context. All six subpaths PASS assert-node-loadable in both conditions.
v0.13.0minor

Wave 3 LMS absorbs + platform fixes. lms: certificate-helper enum repairs (valid vs active — verification could never succeed), own-record access resolves MEMBER id, effectiveAwardStatus predicates, award-chain gate + converging reconciler job, certificate expiry sweep (fixes the stalled T-7/T-0 progression), instructorRoles via rolesSlug knob, createdBy/owner authority split, prerequisiteStrictness (G1), ojt_signoff lessonType with approveOJT guard enforced, packaging guards wired. gamification: dist ships extensioned specifiers (raw-Node loadable; lms barrel dependency).

  • Wave 3 LMS absorbs + platform fixes. lms: certificate-helper enum repairs (valid vs active — verification could never succeed), own-record access resolves MEMBER id, effectiveAwardStatus predicates, award-chain gate + converging reconciler job, certificate expiry sweep (fixes the stalled T-7/T-0 progression), instructorRoles via rolesSlug knob, createdBy/owner authority split, prerequisiteStrictness (G1), ojt_signoff lessonType with approveOJT guard enforced, packaging guards wired. gamification: dist ships extensioned specifiers (raw-Node loadable; lms barrel dependency).
  • Updated dependencies - @wabbit/tome-gamification@0.3.1
v0.12.1patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched. - @wabbit/tome-gamification@0.3.0

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched. - @wabbit/tome-gamification@0.3.0
v0.12.0minor

47a8d78: LMS follow-ups surfaced by the tome-starter demo's real LMS handlers: - `assignment-uploads` now sets `filesRequiredOnCreate: false` so text/url/multiple-choice submissions persist without a synthesized placeholder file; a new `beforeValidate` hook still rejects `submissionType: 'file'` writes that carry no actual upload. - `gradeQuizAttempt` grades against the quiz block matching the attempt's `quizBlockId` (returning null when the id matches no quiz block on the lesson) instead of always grading the first quiz block; attempts without a `quizBlockId` keep the first-quiz-block fallback. - `student-notes` gains an optional `sectionId` text field backing the NotesPanel content-section anchor, surfaced via `getStudentNotes` / `StudentNoteData`. Consumers on 0.11.x are unaffected at runtime: the new `sectionId` field is optional, the loosened upload contract only removes a create-time rejection, and legacy quiz attempts (no `quizBlockId`) grade exactly as before.

  • 47a8d78: LMS follow-ups surfaced by the tome-starter demo's real LMS handlers: - `assignment-uploads` now sets `filesRequiredOnCreate: false` so text/url/multiple-choice submissions persist without a synthesized placeholder file; a new `beforeValidate` hook still rejects `submissionType: 'file'` writes that carry no actual upload. - `gradeQuizAttempt` grades against the quiz block matching the attempt's `quizBlockId` (returning null when the id matches no quiz block on the lesson) instead of always grading the first quiz block; attempts without a `quizBlockId` keep the first-quiz-block fallback. - `student-notes` gains an optional `sectionId` text field backing the NotesPanel content-section anchor, surfaced via `getStudentNotes` / `StudentNoteData`. Consumers on 0.11.x are unaffected at runtime: the new `sectionId` field is optional, the loosened upload contract only removes a create-time rejection, and legacy quiz attempts (no `quizBlockId`) grade exactly as before.
  • @wabbit/tome-gamification@0.3.0
v0.11.0minor

68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognizes the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behavior change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.

  • 68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognizes the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behavior change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.
  • Updated dependencies [68465b3] - @wabbit/tome-gamification@0.3.0
v0.10.1patch

1173d00: Two fixes from the wabbit EDU Phase 5 prod dogfood (2026-07-18): - **lms-ui:** CurriculumSidebar now derives per-row effective access via `deriveAccessState` (enrollment/tier-aware) instead of disabling every `locked`-visibility row — enrolled members can navigate locked lessons from the rail, matching what the content pane already grants. `CurriculumTree` gains an optional `resolveLessonAccess` prop; without it the visibility-tier fallback (anon/landing behavior) is unchanged. - **lms:** the `course-completion` badge check moved out of `onLessonCompletion` (it fired per LESSON, awarding course-completion badges on a student's first completed lesson) into a new `awardCourseCompletionBadges` CourseEnrollment afterChange hook guarded on the status transition into `completed` — the same guard `autoAwardCertification` uses. Per-lesson points (and the points-threshold badge cascade inside `awardPoints`) are unchanged.

  • 1173d00: Two fixes from the wabbit EDU Phase 5 prod dogfood (2026-07-18): - **lms-ui:** CurriculumSidebar now derives per-row effective access via `deriveAccessState` (enrollment/tier-aware) instead of disabling every `locked`-visibility row — enrolled members can navigate locked lessons from the rail, matching what the content pane already grants. `CurriculumTree` gains an optional `resolveLessonAccess` prop; without it the visibility-tier fallback (anon/landing behavior) is unchanged. - **lms:** the `course-completion` badge check moved out of `onLessonCompletion` (it fired per LESSON, awarding course-completion badges on a student's first completed lesson) into a new `awardCourseCompletionBadges` CourseEnrollment afterChange hook guarded on the status transition into `completed` — the same guard `autoAwardCertification` uses. Per-lesson points (and the points-threshold badge cascade inside `awardPoints`) are unchanged.
v0.10.0minor

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: `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.
  • a93f478: `getStudentDashboard` and `getCourseLandingData` no longer fetch sequentially: the dashboard's nine member-scoped queries run in one `Promise.all` batch, the course landing page runs course-by-slug then a five-way parallel batch (outline, reviews, enrollment, eligibility, related courses). Per-call error semantics preserved exactly (independently-guarded calls keep their own try/catch fallbacks; previously-unguarded calls still propagate). Verified safe: no `req`/transaction is threaded into these reads, so there is no session-concurrency hazard.
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [aef2725] - @wabbit/tome-core@1.4.0 - @wabbit/tome-gamification@0.2.1
v0.9.3patch

Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-gamification@0.2.0

  • Updated dependencies [66f394b] - @wabbit/tome-core@1.3.4 - @wabbit/tome-gamification@0.2.0
v0.9.2patch

Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-gamification@0.2.0

  • Updated dependencies - @wabbit/tome-core@1.3.3 - @wabbit/tome-gamification@0.2.0
v0.9.1patch

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.
  • 850d51c: Fix `assignment-uploads` upload collection rejecting every file. It set `mimeTypes: ['*/*']`, but Payload's `validateMimeType` strips only the first `*` (`'*/*'` → `'/*'`), so the wildcard matched no detected MIME type and the upload guard blocked all student file submissions. Removed the broken config — omitting `mimeTypes` is the correct "accept any file" setting, and Payload still blocks dangerous executable types via its built-in `checkFileRestrictions` allowlist.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-core@1.2.1
v0.9.0minor

03865f0: Fix: LMS enrollment/completion access checks now resolve the authenticated user → member profile before querying `student`-keyed rows. `CourseEnrollment.student`, `LessonCompletion.student`, and `GradebookEntry.student` all relate to the **member** collection, but the guards (`isEnrolled`, `hasActiveAccess`, `canAccessLesson`, `canAccessCourseItem`) and the `enrollment-gated` lesson-read access function previously queried them by the auth **user** id (`getUserId(req.user)`). In the canonical Tome identity model — `users` is the auth collection, `members` is a separate profile (`member.user → user`) — those ids differ, so every enrollment/completion check silently failed for genuinely enrolled members: locked lesson bodies were stripped server-side and the API gate denied them. Resolution now routes through `resolveMemberFromSession` (the same platform resolver consumers use for their auth context) via a new `resolveStudentId` helper, cached per request/guard-context. Course **staff** fields (`owner`/`instructors`/`maintainers`) relate to `users`, so those queries correctly keep using the user id — that split is the actual correctness boundary. `GuardContext` gains an optional `memberSlug` (default `'members'`), and `registerLmsLayer` threads the configured member slug into the gated access function. No consumer code change required for the default `'members'` slug. Consumers in a users-auth + members-profile model gain correct enrolled-member access (SSR body-stripping and the `lessonReadAccess: 'enrollment-gated'` API gate both now grant locked lessons to actually-enrolled members).

  • 03865f0: Fix: LMS enrollment/completion access checks now resolve the authenticated user → member profile before querying `student`-keyed rows. `CourseEnrollment.student`, `LessonCompletion.student`, and `GradebookEntry.student` all relate to the **member** collection, but the guards (`isEnrolled`, `hasActiveAccess`, `canAccessLesson`, `canAccessCourseItem`) and the `enrollment-gated` lesson-read access function previously queried them by the auth **user** id (`getUserId(req.user)`). In the canonical Tome identity model — `users` is the auth collection, `members` is a separate profile (`member.user → user`) — those ids differ, so every enrollment/completion check silently failed for genuinely enrolled members: locked lesson bodies were stripped server-side and the API gate denied them. Resolution now routes through `resolveMemberFromSession` (the same platform resolver consumers use for their auth context) via a new `resolveStudentId` helper, cached per request/guard-context. Course **staff** fields (`owner`/`instructors`/`maintainers`) relate to `users`, so those queries correctly keep using the user id — that split is the actual correctness boundary. `GuardContext` gains an optional `memberSlug` (default `'members'`), and `registerLmsLayer` threads the configured member slug into the gated access function. No consumer code change required for the default `'members'` slug. Consumers in a users-auth + members-profile model gain correct enrolled-member access (SSR body-stripping and the `lessonReadAccess: 'enrollment-gated'` API gate both now grant locked lessons to actually-enrolled members).
v0.8.0minor

d2d0b0d: Extract the generic gamification primitives (the `points` append-only ledger, `badges`, `achievements`, plus `awardPoints`/`checkAndAwardBadges`) into a new standalone `@wabbit/tome-gamification` package. `@wabbit/tome-lms` now depends on it and re-exports the three collections + utilities from their original import paths — fully non-breaking for existing consumers (`registerLmsLayer`, the barrel, the access module, and the `onLessonCompletion` hook are unchanged). The points collection gains one additive, optional `source` group (polymorphic `sourceType`/`sourceId`) for non-course consumers. In the new package, `reason` options and the `course`/`media` relations are configurable via `createPointsCollection`/`createBadgeCollection`/`createAchievementCollection` factories (the static `PointsCollection`/`BadgeCollection`/`AchievementCollection` exports preserve the exact LMS shape). New server helpers `getPointsBalance`/`getPointsSince` ship at `@wabbit/tome-gamification/server` for honest windowed totals (e.g. "points this week").

  • d2d0b0d: Extract the generic gamification primitives (the `points` append-only ledger, `badges`, `achievements`, plus `awardPoints`/`checkAndAwardBadges`) into a new standalone `@wabbit/tome-gamification` package. `@wabbit/tome-lms` now depends on it and re-exports the three collections + utilities from their original import paths — fully non-breaking for existing consumers (`registerLmsLayer`, the barrel, the access module, and the `onLessonCompletion` hook are unchanged). The points collection gains one additive, optional `source` group (polymorphic `sourceType`/`sourceId`) for non-course consumers. In the new package, `reason` options and the `course`/`media` relations are configurable via `createPointsCollection`/`createBadgeCollection`/`createAchievementCollection` factories (the static `PointsCollection`/`BadgeCollection`/`AchievementCollection` exports preserve the exact LMS shape). New server helpers `getPointsBalance`/`getPointsSince` ship at `@wabbit/tome-gamification/server` for honest windowed totals (e.g. "points this week").
v0.7.0minor

d5d81ce: Add `lessonReadAccess: 'public' | 'enrollment-gated'` config knob. Under `'enrollment-gated'`, the lessons collection's read access becomes an async Where-filter Access: locked lessons are excluded from REST/GraphQL reads unless the caller has an active enrollment in a course containing the lesson (resolved through the CourseItem junction, cached per request on `req.context.tomeLms`), is owner/instructor/maintainer of such a course, holds a maintainer-tier-or-higher role, or is an internal/system call. Free and preview lessons stay publicly readable. Default `'public'` preserves the as-shipped behavior exactly; consumer SSR via the Local API is unaffected either way (`overrideAccess` default). New exports: `enrollmentGatedLessonRead`, `buildLessonRead`. Also corrects the stale `registerLayer` version literal (0.3.4 → 0.7.0).

  • d5d81ce: Add `lessonReadAccess: 'public' | 'enrollment-gated'` config knob. Under `'enrollment-gated'`, the lessons collection's read access becomes an async Where-filter Access: locked lessons are excluded from REST/GraphQL reads unless the caller has an active enrollment in a course containing the lesson (resolved through the CourseItem junction, cached per request on `req.context.tomeLms`), is owner/instructor/maintainer of such a course, holds a maintainer-tier-or-higher role, or is an internal/system call. Free and preview lessons stay publicly readable. Default `'public'` preserves the as-shipped behavior exactly; consumer SSR via the Local API is unaffected either way (`overrideAccess` default). New exports: `enrollmentGatedLessonRead`, `buildLessonRead`. Also corrects the stale `registerLayer` version literal (0.3.4 → 0.7.0).
v0.6.2patch

Updated dependencies [a9801fe]

  • Updated dependencies [a9801fe]
  • Updated dependencies [baf401e] - @wabbit/tome-core@1.1.0
v0.6.1patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-catalog@1.1.3

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12 - @wabbit/tome-catalog@1.1.3
v0.6.0minor

56fc8d5: Add `getCompletedLessonIds()` — the read-side counterpart to `computeProgress`. `computeProgress` already collapses the canonical completion source (global `lesson-completions` rows for `honor-prior`; the enrollment's own `completedItems` for `fresh-start`) into a 0–100 percentage. UI surfaces (`MarkCompleteButton`, `CurriculumSidebar` completion markers) need the actual id _set_, not the percentage, and the platform deliberately keeps no denormalized `completedLessons` array on the enrollment — so there was no supported way to ask "which lessons has this student completed in this course?". `getCompletedLessonIds({ payload, studentId, courseId, enrollment? })` returns that set as `string[]`, reading from the exact same source `computeProgress` uses (so the two can never disagree), scoped to the course's flattened curriculum. Returns `[]` on lookup failure or empty curriculum — never throws. Consumers pass the result into `<CourseShell completedLessonIds>`.

  • 56fc8d5: Add `getCompletedLessonIds()` — the read-side counterpart to `computeProgress`. `computeProgress` already collapses the canonical completion source (global `lesson-completions` rows for `honor-prior`; the enrollment's own `completedItems` for `fresh-start`) into a 0–100 percentage. UI surfaces (`MarkCompleteButton`, `CurriculumSidebar` completion markers) need the actual id _set_, not the percentage, and the platform deliberately keeps no denormalized `completedLessons` array on the enrollment — so there was no supported way to ask "which lessons has this student completed in this course?". `getCompletedLessonIds({ payload, studentId, courseId, enrollment? })` returns that set as `string[]`, reading from the exact same source `computeProgress` uses (so the two can never disagree), scoped to the course's flattened curriculum. Returns `[]` on lookup failure or empty curriculum — never throws. Consumers pass the result into `<CourseShell completedLessonIds>`.
  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11 - @wabbit/tome-catalog@1.1.2
v0.4.0minor

**NEW: `autoAwardCertification` afterChange hook on CourseEnrollment.** Fires on status transition to `'completed'`. Reads `Course.certificationAwarded` (relationship → certifications) and creates a CertificationAward against that certification for the enrolled student. Skips silently when `certificationAwarded` is null (course awards no cert). Idempotent (existence-check on `recipient + relatedCourseEnrollment` pair). Sets `req.context.internal = true` around the create so `enforceApprovalStatus` bypasses its gate — system writes already have authority to set `approvalStatus: 'approved'` directly. Wired into `CourseEnrollment.afterChange` after `computeProgress`. Promotes the consumer-side auto-award pattern from `wabbit-site-core/dal/lms-completion.ts` to a single platform-owned hook. Per convergence follow-up 4c.

  • **NEW: `autoAwardCertification` afterChange hook on CourseEnrollment.** Fires on status transition to `'completed'`. Reads `Course.certificationAwarded` (relationship → certifications) and creates a CertificationAward against that certification for the enrolled student. Skips silently when `certificationAwarded` is null (course awards no cert). Idempotent (existence-check on `recipient + relatedCourseEnrollment` pair). Sets `req.context.internal = true` around the create so `enforceApprovalStatus` bypasses its gate — system writes already have authority to set `approvalStatus: 'approved'` directly. Wired into `CourseEnrollment.afterChange` after `computeProgress`. Promotes the consumer-side auto-award pattern from `wabbit-site-core/dal/lms-completion.ts` to a single platform-owned hook. Per convergence follow-up 4c.
  • **`enforceApprovalStatus` now respects `req.context.internal === true`.** System writes (hook-initiated, internal jobs) bypass the approval gate. The auto-award hook above uses this. Sites that want to mirror the pattern from their own code can set `req.context.internal = true` before a `payload.create` to the certification-awards collection.
  • **BEHAVIOR CHANGE: `flattenCurriculumTree` now throws on lookup failure.** The prior silent `return []` on caught exception was an anti-pattern that hid production bugs as "empty curriculum" (progress always 0%, dashboard shows nothing) with no log signal. Failures now propagate with the courseId in the message + the original error chained via `cause`. Consumers wanting the old behavior can wrap the call in their own try/catch. Per convergence follow-up 4a.
v0.2.0minor

Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

  • Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.
  • Updated dependencies - @wabbit/tome-core@0.2.0

Lms Ui

v0.12.1
v0.12.1patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.12.0minor

afcd77b: Fixes lesson navigation on nested curricula and adds opt-in labels, heading levels, prerequisite locks and a server-renderable notes index to the learner UI. Additive, with default output unchanged (the default-DOM pins still pass): - `CertificateCard` takes `labels` (`issued`, `expires`, `viewCertificate`, `verifyCertificate`, `download`); `DashboardCertificateList` threads them through a new `cardLabels` prop. Exports `DEFAULT_CERTIFICATE_CARD_LABELS` and `CertificateCardLabels`. - `PrerequisiteGate` takes `labels` (`title`, `fallback`). Exports `DEFAULT_PREREQUISITE_GATE_LABELS`. - `DashboardReplies` takes `headingLevel` (2 to 6, default 4). The "earlier" group heading renders one level above it. - `CertificateTemplate` (`layout="sheet"`) takes `recipientHeadingLevel` (1 to 6, default 1). - `CurriculumSidebar` takes `lockedLessonIds`; with `extendedHooks`, a listed lesson reads `data-lesson-state="locked"`, prints the locked word and gets `data-prerequisite-locked="true"`. The row stays clickable so the learner reaches the prerequisite gate. - `AssignmentRenderer` takes `showInstructions` (default false) and renders authored instructions as paragraphs. `AssignmentBlock` now passes `allowResubmit` and `showInstructions` through, from its props or the block's own fields, and `createAssignmentBlock(options)` builds a configured renderer for `blockRenderers`. - `DashboardNotesIndex` no longer needs a client boundary: it attaches handlers only when a callback prop is passed. New props: `courseOptions`, `noteHref` (string template with `{id}`, or a function), `filterAction` (plain GET form) with `filterSubmitLabel`, `searchParam`, `courseParam`, and `pageHref` with `pageParam`. `NoteSummaryLike` gains an optional `excerpt`, printed under the row when present. Correctness fixes: - `PrevNextNav` and `flattenCourseItems` now walk nested `topic.items` (and `topicItem`, `moduleItem`, or an item's own `items`) as well as the flat `parent` shape, so previous and next render for the nested shape `CurriculumSidebar` uses. Flat input gives the same output as before. - `CertificateVerificationView` now fills `{issuer}` in the expired text and the not-found texts; a literal "{issuer}" no longer prints. - `MarkCompleteButton` now reads the lesson's `lessonType` (falling back to the legacy `type`), so Mark complete is disabled on quiz and assignment lessons as intended. Output changes for those lessons: the button is now disabled and shows the auto-complete hint. - **BREAKING:** `CourseShell` now merges `blockRenderers` over `defaultBlockRenderers` (your entries win) instead of replacing the map, as the prop's docs say. Migration: a consumer that passed a partial map and relied on the missing block types rendering nothing must now pass a renderer for them, or accept the default.

  • afcd77b: Fixes lesson navigation on nested curricula and adds opt-in labels, heading levels, prerequisite locks and a server-renderable notes index to the learner UI. Additive, with default output unchanged (the default-DOM pins still pass): - `CertificateCard` takes `labels` (`issued`, `expires`, `viewCertificate`, `verifyCertificate`, `download`); `DashboardCertificateList` threads them through a new `cardLabels` prop. Exports `DEFAULT_CERTIFICATE_CARD_LABELS` and `CertificateCardLabels`. - `PrerequisiteGate` takes `labels` (`title`, `fallback`). Exports `DEFAULT_PREREQUISITE_GATE_LABELS`. - `DashboardReplies` takes `headingLevel` (2 to 6, default 4). The "earlier" group heading renders one level above it. - `CertificateTemplate` (`layout="sheet"`) takes `recipientHeadingLevel` (1 to 6, default 1). - `CurriculumSidebar` takes `lockedLessonIds`; with `extendedHooks`, a listed lesson reads `data-lesson-state="locked"`, prints the locked word and gets `data-prerequisite-locked="true"`. The row stays clickable so the learner reaches the prerequisite gate. - `AssignmentRenderer` takes `showInstructions` (default false) and renders authored instructions as paragraphs. `AssignmentBlock` now passes `allowResubmit` and `showInstructions` through, from its props or the block's own fields, and `createAssignmentBlock(options)` builds a configured renderer for `blockRenderers`. - `DashboardNotesIndex` no longer needs a client boundary: it attaches handlers only when a callback prop is passed. New props: `courseOptions`, `noteHref` (string template with `{id}`, or a function), `filterAction` (plain GET form) with `filterSubmitLabel`, `searchParam`, `courseParam`, and `pageHref` with `pageParam`. `NoteSummaryLike` gains an optional `excerpt`, printed under the row when present. Correctness fixes: - `PrevNextNav` and `flattenCourseItems` now walk nested `topic.items` (and `topicItem`, `moduleItem`, or an item's own `items`) as well as the flat `parent` shape, so previous and next render for the nested shape `CurriculumSidebar` uses. Flat input gives the same output as before. - `CertificateVerificationView` now fills `{issuer}` in the expired text and the not-found texts; a literal "{issuer}" no longer prints. - `MarkCompleteButton` now reads the lesson's `lessonType` (falling back to the legacy `type`), so Mark complete is disabled on quiz and assignment lessons as intended. Output changes for those lessons: the button is now disabled and shows the auto-complete hint. - **BREAKING:** `CourseShell` now merges `blockRenderers` over `defaultBlockRenderers` (your entries win) instead of replacing the map, as the prop's docs say. Migration: a consumer that passed a partial map and relied on the missing block types rendering nothing must now pass a renderer for them, or accept the default.
  • a3c9b0f: The learner UI is themeable: colors are tokens, strings take labels, and the player gains new pieces. The learner UI is now themeable: raw colors are tokens, fixed strings take `labels`, and the player gains a lesson eyebrow, progress counts, a buy state and a practice quiz mode. - Every raw color in the stylesheets now reads a token with today's value as its fallback, so nothing changes until a theme sets the token. New roles (`--lms-color-success-text`, `-soft`, `--lms-color-on-fill`, `--lms-shadow-card` and others) are documented in `tokens.css` and deliberately unassigned; `no-literal-colors` guards the rule. - `QuizRenderer`, `AssignmentRenderer`, `NotesPanel`, `LessonContent`, `LessonVideo`, `ProgressBar`, `CourseCatalogList`, `CertificatePass` and the dashboard lists take a `labels` prop and export `DEFAULT_*_LABELS` equal to today's copy. `CourseShell` takes `labels` for the components inside the player, and `extendedHooks` for extra `data-lms-*` hooks (status badges, `data-figure`) on existing elements; both are off by default. - `ProgressBar` accepts `counts` and `labelFormat`; `LessonContent` accepts `eyebrow`; `EnrollmentCTA` gains a `buy` state with `href` and `price`, and works as a link without script. - `QuizRenderer` accepts `mode="practice"` (or a `mode` key on the block): no pass or fail verdict, score or threshold, answers shown as text with their reasons, and "Clear and try again". Attempts are still recorded. - `AssignmentRenderer` shows a reviewed date when the submission has `reviewedAt`, and `allowResubmit` brings the form back after a revision request. - `CourseShell` and `LearnerShell` read `--lms-app-max-width` (default `none`, which follows `--tome-app-max-width`); `CourseShellLayout` accepts `mobileDrawers` for closable curriculum and notes drawers on phones.
  • 78750a2: The learner app gains dashboard views, a flat rail and a document-style certificate, all opt-in. The learner app gains its dashboard pieces, a flat rail and a certificate that reads as a document. Everything is opt-in: with default props the output is unchanged (default-DOM pins guard `LearnerShell`, `CertificateTemplate` classic and sheet, and `CertificateVerificationView`). - New Server Components `DashboardOverview` (Continue, since the last visit, one quiet line for the other course, a welcome for a new student), `CourseWorkspace` (course list beside the selected course, with a native GET picker on phones) and `DashboardReplies` ("New" means newer than the previous visit you pass in; nothing is stored). They take plain data, render with no script, and accept no streak, point or stat tile. - `DashboardGradeTable` gains `layout="by-course"` with `groups`: each item shows a status word (Passed, Needs another go, Submitted, Reviewed, Not taken yet), a score and plain Retake and Read why links. Row handlers attach only when `onRowClick` is given. `DashboardCertificateList` gains `stillToEarn`. - `LearnerShell` gains `layout="rail"` with `rail`: one `nav` of plain links, `aria-current="page"` on the current route, counts only above zero. `flattenLearnerNav` in `@wabbit/tome-lms-ui/nav` builds rail items from the manifests. - `CertificateTemplate` (sheet layout) takes `labels`, `sheet.facts` (a sentence built from computed course facts, with `formatCourseMinutes` for durations) and `sheet.proportion="a4-landscape"` (container-scaled frame that prints as one landscape page through a named `@page`; the classic layout and its portrait page rule are untouched). `CertificateVerificationView` takes `labels` and `courseFacts` ("What the course covered", never on not-found).
  • c54faf2: Adds styling hooks to the dashboard, replies and curriculum components for themes. Hooks the Schoolroom treatments needed. New components (unreleased SR1-k pieces) gain new attributes; the existing `CurriculumSidebar` changes only behind `extendedHooks`, so wabbit.com's default DOM is unchanged (the default-DOM pins pass). - `DashboardOverview`: the Continue lesson title is wrapped in `span[data-lms-element="continue-lesson-title"]`, separate from `continue-minutes`, so a theme can wash the title alone. The `p[data-lms-element="continue-lesson"]` and its text stay as SR1-k states. - `DashboardOverview`: a since line flagged `showNew` also carries `data-is-new="true"`. `data-new="true"` stays on every since line, the attribute contract SR1-k states; `data-is-new` is the hook for "actually new". - `DashboardReplies`: `ReplyLike.reviewerImage` (`{ url, alt? }`, optional) prints a small portrait first on the reply line as `img[data-lms-element="reply-portrait"]` (empty alt beside the printed name, decorative). Omitted, the markup is unchanged. - `CurriculumSidebar`: with the shell's `extendedHooks`, each lesson row carries `data-lesson-state` (`active`, `locked`, `done`, `available`, in that precedence) and a locked row prints the word in `span[data-lms-element="lesson-row-locked"]` (default "Locked", relabelled through the new `labels` prop or the shell's `labels.curriculum`). New `CurriculumSidebarLabels` and `DEFAULT_CURRICULUM_SIDEBAR_LABELS`.
v0.11.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.11.1patch

acb7188: `DashboardCertificateList` gains `hrefBase`, a plain-string link base a Server Component can pass; `hrefFor` (a function) only works from a Client Component. - `hrefBase="/my/certificates"` links each card to `/my/certificates/{id}`. `hrefFor` still wins when both are set. - Passing `hrefFor` from a Server Component throws in Next.js ("Functions cannot be passed directly to Client Components"), because the list is a Client Component. - `CertificatePass` draws its dark card with tome-ui's `surface-solid-dark` / `on-solid-dark` pair. It used `on-inverse`, which is dark text for the white `surface-inverse`, so the card's text rendered dark on dark.

  • acb7188: `DashboardCertificateList` gains `hrefBase`, a plain-string link base a Server Component can pass; `hrefFor` (a function) only works from a Client Component. - `hrefBase="/my/certificates"` links each card to `/my/certificates/{id}`. `hrefFor` still wins when both are set. - Passing `hrefFor` from a Server Component throws in Next.js ("Functions cannot be passed directly to Client Components"), because the list is a Client Component. - `CertificatePass` draws its dark card with tome-ui's `surface-solid-dark` / `on-solid-dark` pair. It used `on-inverse`, which is dark text for the white `surface-inverse`, so the card's text rendered dark on dark.
v0.11.0minor

0dc8077: New certificate pass, sheet layout and four-state verification page components; the old `verificationResult` prop still works and is deprecated. - `CertificatePass`: dark pass card with a server-rendered QR of at least 176px, grouped verification code, four facts, share / open-verify / optional download actions and a link card to the full certificate. - `CertificateVisibilityToggle`: the holder's per-certificate "show my name on the public verify page" switch, driven by a consumer-supplied server action. - `VerificationLookupForm` (plain GET code lookup) and `PrintButton`. - `CertificateTemplate` gains `layout="sheet"` with a `sheet` details prop (holder, course, credential, hours, signatory, what it certifies, record). - `CertificateVerificationView` takes a `verification` prop shaped like `getPublicCertificateVerification` and renders verdict, record, "About this check" and a lookup form for the valid, expired, revoked and not-found states. The previous `verificationResult` prop still works for one minor and is deprecated. - `DashboardCertificateList` accepts `hrefFor` to link each card to its single-certificate route; `QRVerificationPayload` gains `sizePx`. - New CSS exports: `./components/certificate-pass`, `./components/certificate-visibility-toggle`, `./components/verification-lookup-form`.

  • 0dc8077: New certificate pass, sheet layout and four-state verification page components; the old `verificationResult` prop still works and is deprecated. - `CertificatePass`: dark pass card with a server-rendered QR of at least 176px, grouped verification code, four facts, share / open-verify / optional download actions and a link card to the full certificate. - `CertificateVisibilityToggle`: the holder's per-certificate "show my name on the public verify page" switch, driven by a consumer-supplied server action. - `VerificationLookupForm` (plain GET code lookup) and `PrintButton`. - `CertificateTemplate` gains `layout="sheet"` with a `sheet` details prop (holder, course, credential, hours, signatory, what it certifies, record). - `CertificateVerificationView` takes a `verification` prop shaped like `getPublicCertificateVerification` and renders verdict, record, "About this check" and a lookup form for the valid, expired, revoked and not-found states. The previous `verificationResult` prop still works for one minor and is deprecated. - `DashboardCertificateList` accepts `hrefFor` to link each card to its single-certificate route; `QRVerificationPayload` gains `sizePx`. - New CSS exports: `./components/certificate-pass`, `./components/certificate-visibility-toggle`, `./components/verification-lookup-form`.
v0.10.6patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.10.5patch

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

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

1b1aead: The default course player now composes the on-this-page rail, suggested-next panel and gate components that already ship. The default course-player composition now uses the components that already ship: `CourseShellLayout` (and `CourseShell` without children, which now renders it) fills the on-this-page rail with `OnThisPage` while the lesson is accessible (`onThisPageSlot={null}` leaves it empty); `LessonContent`'s footer renders `SuggestedNextPanel`; a gated lesson renders `PrerequisiteGate` or `VisibilityGate` (with `EnrollmentCTA`) instead of an inline message, with a new `slotOverrides.gate` to replace it; and `VisibilityGate`'s expired branch renders `ExpiredEnrollmentNotice` instead of a placeholder. `ExpiredEnrollmentNotice` and `PrerequisiteGate` gain an optional `headingLevel` (default 3). All prop changes are additive.

  • 1b1aead: The default course player now composes the on-this-page rail, suggested-next panel and gate components that already ship. The default course-player composition now uses the components that already ship: `CourseShellLayout` (and `CourseShell` without children, which now renders it) fills the on-this-page rail with `OnThisPage` while the lesson is accessible (`onThisPageSlot={null}` leaves it empty); `LessonContent`'s footer renders `SuggestedNextPanel`; a gated lesson renders `PrerequisiteGate` or `VisibilityGate` (with `EnrollmentCTA`) instead of an inline message, with a new `slotOverrides.gate` to replace it; and `VisibilityGate`'s expired branch renders `ExpiredEnrollmentNotice` instead of a placeholder. `ExpiredEnrollmentNotice` and `PrerequisiteGate` gain an optional `headingLevel` (default 3). All prop changes are additive.
  • 0aa80a3: Drops the unused `@wabbit/tome-ui` peer dependency; nothing in the package imported it.
  • c8827e7: Counts and dates now render with a pinned `en-US` locale and UTC, so server and browser markup match and hydration no longer mismatches. Covers student, enrollment and leaderboard counts and the enrollment, due, expiry and quiz-cooldown dates; the cooldown time now names its zone (`UTC`). Internal: collection slugs are typed through one package-internal helper instead of inline casts.
v0.10.3patch

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.

  • 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.
  • b01ca1f: Normalize the `react` peer range from `^19.0.0` to `>=19.0.0`. Not a floor raise — lms-ui already required React 19. This is the last of the five competing peer shapes collapsing onto the one the platform now uses everywhere (ruled 2026-09-01), so a reader comparing manifests across packages sees one vocabulary instead of five. The practical difference is that a future React 20 consumer is no longer excluded by punctuation alone.
  • 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.
  • 090e984: README fixes surfaced by the extended `assert:readme-contract` gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source: - **blocks-core** — the `./categories` and `./types` entry points are now named in the Public API section; both were published but undocumented. - **core** — added `/access/orgScoped`, `/access/vendorScoped`, `/infra/health` and `/infra/env-scaffold` to the additional-subpaths table, and noted that `/auth/collections/roles` has a real `/auth/collections/Roles` case alias in the exports map. - **crowdfund** — `CROWDFUND_LAYER_VERSION` is also published standalone at `./version`; the row now says so. - **dispatch** — the eight per-block `./blocks/*` config subpaths and all eight `./components/*` component subpaths are enumerated instead of one "etc." row. - **forms** — the peer table now lists `@wabbit/tome-core`, `@wabbit/tome-ui` and `typescript`, which are declared `peerDependencies` but appeared only in prose (or not at all). - **lms-ui** — `StudentProfileEditor` is flagged `@deprecated` in the component table, matching the tag its source already carries.
v0.10.2patch

019150b: Fixed a defect where `QuizRenderer` and `AssignmentRenderer` never read the authored fields off blocks stored in the nested `{ blockType, blockData: {...} }` shape — the shape EDU's real lesson rows use, with no `id` on the outer block object. `asQuizBlock`/`asAssignmentBlock` returned the block AS-IS and every field read (`quiz.questions`, `quiz.maxAttempts`, `quiz.showCorrectAnswers`, `quiz.passingScore`, `assignment.submissionType`, `assignment.points`, `assignment.choices`, …) came off the OUTER object, which only carries `blockType`/`blockData` — so `questions` was always `undefined`, no quiz question ever rendered (students saw only a bare Submit button), and every submit graded 0/n; assignment blocks silently collapsed to the default `text` submission type regardless of what was authored. Both narrowing functions now unwrap `block.blockData` when present, falling back to the block itself when it's absent — the pre-existing flat shape (`{ id, title, questions, ... }`, still used by every other consumer and by this package's own pre-existing tests) keeps working byte-for-byte. `readBlockId` on both renderers now also falls back through `block.blockData?.id` and `block.blockID` before giving up — EDU's real rows currently carry no id anywhere, so it still returns `undefined` for them, and the two-step quiz-attempt-start / richer-assignment-submit dispatch paths fall back exactly as they did before (`onQuizAttemptStart` receives `''` via its existing `?? ''` guard; `onAssignmentSubmitWithBlock` is skipped in favor of `onAssignmentSubmit` since it only fires when an id is truthy) — no `d.ts` contract change was needed for either. `LessonContent`'s block dispatcher already routed this shape to both renderers correctly (its `blockType` lookup handles the `'quizBlock'`/`'quiz'` and `'assignmentBlock'`/`'assignment'` key spellings) — the renderers' own field reads were the only break. New tests cover both shapes side by side (`QuizRenderer.blockdata-unwrap.test.tsx`, `AssignmentRenderer.blockdata-unwrap.test.tsx`), including a nested fixture copied verbatim from the verified live EDU row shape, and were demonstrated red against the unfixed source before the fix was applied.

  • 019150b: Fixed a defect where `QuizRenderer` and `AssignmentRenderer` never read the authored fields off blocks stored in the nested `{ blockType, blockData: {...} }` shape — the shape EDU's real lesson rows use, with no `id` on the outer block object. `asQuizBlock`/`asAssignmentBlock` returned the block AS-IS and every field read (`quiz.questions`, `quiz.maxAttempts`, `quiz.showCorrectAnswers`, `quiz.passingScore`, `assignment.submissionType`, `assignment.points`, `assignment.choices`, …) came off the OUTER object, which only carries `blockType`/`blockData` — so `questions` was always `undefined`, no quiz question ever rendered (students saw only a bare Submit button), and every submit graded 0/n; assignment blocks silently collapsed to the default `text` submission type regardless of what was authored. Both narrowing functions now unwrap `block.blockData` when present, falling back to the block itself when it's absent — the pre-existing flat shape (`{ id, title, questions, ... }`, still used by every other consumer and by this package's own pre-existing tests) keeps working byte-for-byte. `readBlockId` on both renderers now also falls back through `block.blockData?.id` and `block.blockID` before giving up — EDU's real rows currently carry no id anywhere, so it still returns `undefined` for them, and the two-step quiz-attempt-start / richer-assignment-submit dispatch paths fall back exactly as they did before (`onQuizAttemptStart` receives `''` via its existing `?? ''` guard; `onAssignmentSubmitWithBlock` is skipped in favor of `onAssignmentSubmit` since it only fires when an id is truthy) — no `d.ts` contract change was needed for either. `LessonContent`'s block dispatcher already routed this shape to both renderers correctly (its `blockType` lookup handles the `'quizBlock'`/`'quiz'` and `'assignmentBlock'`/`'assignment'` key spellings) — the renderers' own field reads were the only break. New tests cover both shapes side by side (`QuizRenderer.blockdata-unwrap.test.tsx`, `AssignmentRenderer.blockdata-unwrap.test.tsx`), including a nested fixture copied verbatim from the verified live EDU row shape, and were demonstrated red against the unfixed source before the fix was applied.
v0.10.1patch

f9b479c: Re-exported `QuizAttemptStartResult` from the package root. It was already part of `CourseShellRoot`'s public `onQuizAttemptStart` signature but had no supported import path — consumers had to mirror its shape structurally instead of importing it.

  • f9b479c: Re-exported `QuizAttemptStartResult` from the package root. It was already part of `CourseShellRoot`'s public `onQuizAttemptStart` signature but had no supported import path — consumers had to mirror its shape structurally instead of importing it.
v0.10.0minor

433bec6: CourseShell gains an additive, backward-compatible two-step submit contract for quizzes and assignments. - `onQuizAttemptStart?: (lessonId, quizBlockId) => Promise<{ attemptId, cooldownExpiry?, attemptsRemaining? }>` and `onQuizAttemptSubmit?: (attemptId, answers) => Promise<QuizResult>` let a site wire the platform's two-step `startQuizAttempt` → `submitQuizAttempt` mutation flow (`@wabbit/tome-lms/server`) into `QuizRenderer`. When both are provided, `QuizRenderer` starts an attempt on mount (passing the quiz block's own id), surfaces a disabled/cooldown-expiry submit state when the start response is locked out, and dispatches submit through the attempt id instead of the lesson id. - `onAssignmentSubmitWithBlock?: (lessonId, assignmentBlockId, submission) => Promise<void>` lets `AssignmentRenderer` carry the authored assignment block's own id through to sites whose mutation layer needs it, when the block has an id. - Both are purely additive: `onQuizSubmit`/`onAssignmentSubmit` keep working byte-identically when the new props are absent, and the two-step quiz path only activates when BOTH `onQuizAttemptStart` and `onQuizAttemptSubmit` are wired (partial wiring falls back to the single-call contract).

  • 433bec6: CourseShell gains an additive, backward-compatible two-step submit contract for quizzes and assignments. - `onQuizAttemptStart?: (lessonId, quizBlockId) => Promise<{ attemptId, cooldownExpiry?, attemptsRemaining? }>` and `onQuizAttemptSubmit?: (attemptId, answers) => Promise<QuizResult>` let a site wire the platform's two-step `startQuizAttempt` → `submitQuizAttempt` mutation flow (`@wabbit/tome-lms/server`) into `QuizRenderer`. When both are provided, `QuizRenderer` starts an attempt on mount (passing the quiz block's own id), surfaces a disabled/cooldown-expiry submit state when the start response is locked out, and dispatches submit through the attempt id instead of the lesson id. - `onAssignmentSubmitWithBlock?: (lessonId, assignmentBlockId, submission) => Promise<void>` lets `AssignmentRenderer` carry the authored assignment block's own id through to sites whose mutation layer needs it, when the block has an id. - Both are purely additive: `onQuizSubmit`/`onAssignmentSubmit` keep working byte-identically when the new props are absent, and the two-step quiz path only activates when BOTH `onQuizAttemptStart` and `onQuizAttemptSubmit` are wired (partial wiring falls back to the single-call contract).
v0.9.8patch

e7277a7: Packaging hygiene — `@wabbit/tome-lms` and `@wabbit/tome-ui` move from hard `dependencies` to `peerDependencies` (+ `workspace:*` devDependency twins). No source change, no behavior change. `@wabbit/tome-lms-ui` was the ONLY `layer: app` package in the monorepo declaring `@wabbit/*` packages as runtime `dependencies`. Every sibling — `tome-admin`, `tome-admin-pro`, `tome-chrome`, `tome-dispatch`, `tome-longform`, `tome-readout` — declares its engines as required peers with a `workspace:*` devDependency twin, and carries no `peerDependenciesMeta` entry for them. This package now matches that convention exactly. Both were declared `workspace:^`, which pnpm rewrites at pack time — so the published tarball carried a hard `^0.x` runtime dependency on the LMS engine. A consumer mounting `tome-lms-ui` always mounts `tome-lms` itself (they are one licensable family, `learning`), so npm was installing and version-resolving a second copy of an engine the consumer already supplies. The peer declaration lets the consumer's copy satisfy it. Ranges follow the sibling convention (`>=MIN <NEXTMAJOR`): `@wabbit/tome-ui` at `>=0.9.0 <1.0.0`, matching `tome-chrome`/`tome-dispatch`/`tome-longform`/`tome-readout`/`tome-admin` verbatim. `@wabbit/tome-lms` at `>=0.12.0 <1.0.0` — deliberately holding the floor the published tarball already carried rather than raising it to the current 0.14.0, so this change alters the KIND of dependency without narrowing the version contract consumers already rely on. (`tome-sc` declares the same engine at `>=0.9.0 <1.0.0`; the higher floor here is the conservative choice, not a conflict.) Both peers are REQUIRED (no `peerDependenciesMeta` entry), which is the honest declaration and matches the sibling packages: - `@wabbit/tome-lms` is statically value-imported in exactly one place — `src/server/active-quest.ts` imports `getActiveEnrollments`/`getLessonChain` from `@wabbit/tome-lms/server`, reachable via the `./server` subpath. Everything else referencing the engine is a comment or a deliberately re-declared type (see `src/types.ts`, which re-declares `CertificationAwardData` specifically so consumers "don't have to depend directly on @wabbit/tome-lms types just for the shell"). An optional peer would be dishonest for `./server`. - `@wabbit/tome-ui` has ZERO imports anywhere in `src/` — its only references are prose comments and three CSS files that consume `--tome-color-*` Layer-2 tokens shipped by `@wabbit/tome-ui/tokens`. The dependency is real but ambient: the consumer must load those tokens for this package to render correctly. A peer declares that requirement without forcing a runtime install of a module nothing imports, which is precisely what the sibling packages do. The `workspace:*` devDependency twins are required, not optional bookkeeping: `assert:declared-imports` fails a statically-imported internal package that is peer-only ("add a workspace:\* devDependency so pnpm topology orders the build"), because peers are invisible to pnpm's build ordering on cold checkouts. `pnpm assert:declared-imports` is green after the change. Guarded going forward by a new `assert:app-layer-peers` check (repo tooling — no changeset of its own, since it ships no package), wired into `platform-discipline` CI pre-build alongside the other manifest asserts. It fails any `layer: app` package that declares a `@wabbit/*` engine in `dependencies`, and carries an empty, stale-entry-failing allowlist so a future exception has to be argued rather than assumed. NOT published here; publishing waits on the irreversible-publish preflight checklist.

  • e7277a7: Packaging hygiene — `@wabbit/tome-lms` and `@wabbit/tome-ui` move from hard `dependencies` to `peerDependencies` (+ `workspace:*` devDependency twins). No source change, no behavior change. `@wabbit/tome-lms-ui` was the ONLY `layer: app` package in the monorepo declaring `@wabbit/*` packages as runtime `dependencies`. Every sibling — `tome-admin`, `tome-admin-pro`, `tome-chrome`, `tome-dispatch`, `tome-longform`, `tome-readout` — declares its engines as required peers with a `workspace:*` devDependency twin, and carries no `peerDependenciesMeta` entry for them. This package now matches that convention exactly. Both were declared `workspace:^`, which pnpm rewrites at pack time — so the published tarball carried a hard `^0.x` runtime dependency on the LMS engine. A consumer mounting `tome-lms-ui` always mounts `tome-lms` itself (they are one licensable family, `learning`), so npm was installing and version-resolving a second copy of an engine the consumer already supplies. The peer declaration lets the consumer's copy satisfy it. Ranges follow the sibling convention (`>=MIN <NEXTMAJOR`): `@wabbit/tome-ui` at `>=0.9.0 <1.0.0`, matching `tome-chrome`/`tome-dispatch`/`tome-longform`/`tome-readout`/`tome-admin` verbatim. `@wabbit/tome-lms` at `>=0.12.0 <1.0.0` — deliberately holding the floor the published tarball already carried rather than raising it to the current 0.14.0, so this change alters the KIND of dependency without narrowing the version contract consumers already rely on. (`tome-sc` declares the same engine at `>=0.9.0 <1.0.0`; the higher floor here is the conservative choice, not a conflict.) Both peers are REQUIRED (no `peerDependenciesMeta` entry), which is the honest declaration and matches the sibling packages: - `@wabbit/tome-lms` is statically value-imported in exactly one place — `src/server/active-quest.ts` imports `getActiveEnrollments`/`getLessonChain` from `@wabbit/tome-lms/server`, reachable via the `./server` subpath. Everything else referencing the engine is a comment or a deliberately re-declared type (see `src/types.ts`, which re-declares `CertificationAwardData` specifically so consumers "don't have to depend directly on @wabbit/tome-lms types just for the shell"). An optional peer would be dishonest for `./server`. - `@wabbit/tome-ui` has ZERO imports anywhere in `src/` — its only references are prose comments and three CSS files that consume `--tome-color-*` Layer-2 tokens shipped by `@wabbit/tome-ui/tokens`. The dependency is real but ambient: the consumer must load those tokens for this package to render correctly. A peer declares that requirement without forcing a runtime install of a module nothing imports, which is precisely what the sibling packages do. The `workspace:*` devDependency twins are required, not optional bookkeeping: `assert:declared-imports` fails a statically-imported internal package that is peer-only ("add a workspace:\* devDependency so pnpm topology orders the build"), because peers are invisible to pnpm's build ordering on cold checkouts. `pnpm assert:declared-imports` is green after the change. Guarded going forward by a new `assert:app-layer-peers` check (repo tooling — no changeset of its own, since it ships no package), wired into `platform-discipline` CI pre-build alongside the other manifest asserts. It fails any `layer: app` package that declares a `@wabbit/*` engine in `dependencies`, and carries an empty, stale-entry-failing allowlist so a future exception has to be argued rather than assumed. NOT published here; publishing waits on the irreversible-publish preflight checklist.
v0.9.7patch

Updated dependencies [57b7a43]

  • Updated dependencies [57b7a43]
  • Updated dependencies [fa9c30b]
  • Updated dependencies [fc30bf6] - @wabbit/tome-lms@0.14.0
v0.9.6patch

Updated dependencies - @wabbit/tome-lms@0.13.0

  • Updated dependencies - @wabbit/tome-lms@0.13.0
v0.9.5patch

Updated dependencies [47a8d78] - @wabbit/tome-lms@0.12.0

  • Updated dependencies [47a8d78] - @wabbit/tome-lms@0.12.0
v0.9.4patch

Updated dependencies [0a070e0] - @wabbit/tome-ui@0.11.0

  • Updated dependencies [0a070e0] - @wabbit/tome-ui@0.11.0
v0.9.3patch

Updated dependencies [68465b3] - @wabbit/tome-lms@0.11.0

  • Updated dependencies [68465b3] - @wabbit/tome-lms@0.11.0
v0.9.1patch

Updated dependencies - @wabbit/tome-ui@0.10.0

  • Updated dependencies - @wabbit/tome-ui@0.10.0
v0.9.0minor

CourseCatalogList: the course-card CTA no longer prefers --pc-_ brand tokens or falls back to the safety-yellow literal; it now rides --lms-color-primary-cta → --tome-color-primary (neutral). Industrial-lineage consumers get the previous look via @wabbit/tome-blocks-industrial-theme, which defines the --lms-color-_ hooks inside its theme scope.

  • CourseCatalogList: the course-card CTA no longer prefers --pc-_ brand tokens or falls back to the safety-yellow literal; it now rides --lms-color-primary-cta → --tome-color-primary (neutral). Industrial-lineage consumers get the previous look via @wabbit/tome-blocks-industrial-theme, which defines the --lms-color-_ hooks inside its theme scope.
v0.8.1patch

1173d00: Two fixes from the wabbit EDU Phase 5 prod dogfood (2026-07-18): - **lms-ui:** CurriculumSidebar now derives per-row effective access via `deriveAccessState` (enrollment/tier-aware) instead of disabling every `locked`-visibility row — enrolled members can navigate locked lessons from the rail, matching what the content pane already grants. `CurriculumTree` gains an optional `resolveLessonAccess` prop; without it the visibility-tier fallback (anon/landing behavior) is unchanged. - **lms:** the `course-completion` badge check moved out of `onLessonCompletion` (it fired per LESSON, awarding course-completion badges on a student's first completed lesson) into a new `awardCourseCompletionBadges` CourseEnrollment afterChange hook guarded on the status transition into `completed` — the same guard `autoAwardCertification` uses. Per-lesson points (and the points-threshold badge cascade inside `awardPoints`) are unchanged.

  • 1173d00: Two fixes from the wabbit EDU Phase 5 prod dogfood (2026-07-18): - **lms-ui:** CurriculumSidebar now derives per-row effective access via `deriveAccessState` (enrollment/tier-aware) instead of disabling every `locked`-visibility row — enrolled members can navigate locked lessons from the rail, matching what the content pane already grants. `CurriculumTree` gains an optional `resolveLessonAccess` prop; without it the visibility-tier fallback (anon/landing behavior) is unchanged. - **lms:** the `course-completion` badge check moved out of `onLessonCompletion` (it fired per LESSON, awarding course-completion badges on a student's first completed lesson) into a new `awardCourseCompletionBadges` CourseEnrollment afterChange hook guarded on the status transition into `completed` — the same guard `autoAwardCertification` uses. Per-lesson points (and the points-threshold badge cascade inside `awardPoints`) are unchanged.
  • Updated dependencies [1173d00] - @wabbit/tome-lms@0.10.1
v0.8.0minor

6bc419c: `StudentProfileForm` gains a `tag-list` field type (built on `useTagList`) and an exported `studentProfileEditorSchema(availableArchetypes?)` reproducing `StudentProfileEditor`'s field set through the schema vocabulary; Editor is `@deprecated` with the exact migration recipe in its tag. One honest gap, not smoothed over: Editor's free-form `customFields` key-value rows have no schema equivalent — a `key-value-list` field type gets built when a second consumer needs one (trigger named in both docblocks); consumers relying on customFields keep using Editor until then.

  • 6bc419c: `StudentProfileForm` gains a `tag-list` field type (built on `useTagList`) and an exported `studentProfileEditorSchema(availableArchetypes?)` reproducing `StudentProfileEditor`'s field set through the schema vocabulary; Editor is `@deprecated` with the exact migration recipe in its tag. One honest gap, not smoothed over: Editor's free-form `customFields` key-value rows have no schema equivalent — a `key-value-list` field type gets built when a second consumer needs one (trigger named in both docblocks); consumers relying on customFields keep using Editor until then.
  • 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.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-lms@0.10.0 - @wabbit/tome-ui@0.9.9
v0.7.11patch

@wabbit/tome-lms@0.9.3

  • @wabbit/tome-lms@0.9.3
v0.7.10patch

Updated dependencies [ec4b7bc] - @wabbit/tome-ui@0.9.8

  • Updated dependencies [ec4b7bc] - @wabbit/tome-ui@0.9.8
v0.7.9patch

Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling). - @wabbit/tome-lms@0.9.2

  • Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling). - @wabbit/tome-lms@0.9.2
v0.7.8patch

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.
  • Updated dependencies [bed3f90]
  • Updated dependencies [850d51c] - @wabbit/tome-ui@0.9.7 - @wabbit/tome-lms@0.9.1
v0.7.7patch

Updated dependencies - @wabbit/tome-ui@0.9.6

  • Updated dependencies - @wabbit/tome-ui@0.9.6
v0.7.6patch

Updated dependencies - @wabbit/tome-ui@0.9.5

  • Updated dependencies - @wabbit/tome-ui@0.9.5
v0.7.5patch

Updated dependencies [03865f0] - @wabbit/tome-lms@0.9.0

  • Updated dependencies [03865f0] - @wabbit/tome-lms@0.9.0
v0.7.4patch

Updated dependencies [d2d0b0d] - @wabbit/tome-lms@0.8.0

  • Updated dependencies [d2d0b0d] - @wabbit/tome-lms@0.8.0
v0.7.3patch

Updated dependencies [d5d81ce] - @wabbit/tome-lms@0.7.0

  • Updated dependencies [d5d81ce] - @wabbit/tome-lms@0.7.0
v0.7.2patch

@wabbit/tome-lms@0.6.2

  • @wabbit/tome-lms@0.6.2
v0.7.1patch

Updated dependencies [84a047a] - @wabbit/tome-ui@0.9.3

  • Updated dependencies [84a047a] - @wabbit/tome-ui@0.9.3
v0.7.0minor

90d66fa: Tokenize longform + LMS typography to the platform `--tome-text-*` scale **tome-longform** — every hard-coded `font-size` rem/px literal across the 15 block CSS modules (Accordion, AnchorSection, Aside, AuthorAside, Callout, ChapterDivider, CrossLink, DataTable, ImageGrid, KeyFacts, SeriesNav, Spoiler, TabbedContent + DropCap's custom prop left as-is) now reads the platform type token with the original literal preserved as the fallback — e.g. `font-size: var(--tome-text-sm, 0.875rem)`. `em`-based sizes are left untouched (intentional relative sizing). A Tome-themed consumer (one that loads `@wabbit/tome-ui/tokens`) now gets longform type that tracks the platform scale; a consumer with no Tome tokens renders identically to before (fallback = the prior literal). **Non-breaking, but rendered sizes shift in Tome-themed consumers — re-verify longform visually after upgrading.** **SeriesNav** — `.partLink` (which sits on the TOP_BANNER `--block-accent-bg` surface) now leads its color with `--block-accent-text` before falling back to `--tome-color-muted-foreground`. When `--block-accent-text` is unset this is identical to the prior rule (non-regressive); when a consumer sets an accent background they now have a paired on-accent text hook, closing the cream-on-pastel contrast gap by contract rather than by a hard-coded value (mirrors the existing `.seriesLabel` / `.sidebarLabel` pairing). **tome-lms-ui** — `tokens.css` now bridges the `--lms-font-size-*` scale to `--tome-text-*` (mirroring the existing color-token bridge), so LMS _content_ typography tracks the platform scale instead of being a parallel fixed scale. The previously-undefined `xs` / `base` / `xl` / `2xl` names (used across components with inline fallbacks) are now defined. Fallbacks equal the dominant observed intended size, so a non-Tome consumer is non-breaking. `CertificateTemplate` literals are tokenized onto this scale. Dense sidebar/nav chrome keeps its sub-14px px literals — that is intentional UI density, not reading prose, and was deliberately left untokenized.

  • 90d66fa: Tokenize longform + LMS typography to the platform `--tome-text-*` scale **tome-longform** — every hard-coded `font-size` rem/px literal across the 15 block CSS modules (Accordion, AnchorSection, Aside, AuthorAside, Callout, ChapterDivider, CrossLink, DataTable, ImageGrid, KeyFacts, SeriesNav, Spoiler, TabbedContent + DropCap's custom prop left as-is) now reads the platform type token with the original literal preserved as the fallback — e.g. `font-size: var(--tome-text-sm, 0.875rem)`. `em`-based sizes are left untouched (intentional relative sizing). A Tome-themed consumer (one that loads `@wabbit/tome-ui/tokens`) now gets longform type that tracks the platform scale; a consumer with no Tome tokens renders identically to before (fallback = the prior literal). **Non-breaking, but rendered sizes shift in Tome-themed consumers — re-verify longform visually after upgrading.** **SeriesNav** — `.partLink` (which sits on the TOP_BANNER `--block-accent-bg` surface) now leads its color with `--block-accent-text` before falling back to `--tome-color-muted-foreground`. When `--block-accent-text` is unset this is identical to the prior rule (non-regressive); when a consumer sets an accent background they now have a paired on-accent text hook, closing the cream-on-pastel contrast gap by contract rather than by a hard-coded value (mirrors the existing `.seriesLabel` / `.sidebarLabel` pairing). **tome-lms-ui** — `tokens.css` now bridges the `--lms-font-size-*` scale to `--tome-text-*` (mirroring the existing color-token bridge), so LMS _content_ typography tracks the platform scale instead of being a parallel fixed scale. The previously-undefined `xs` / `base` / `xl` / `2xl` names (used across components with inline fallbacks) are now defined. Fallbacks equal the dominant observed intended size, so a non-Tome consumer is non-breaking. `CertificateTemplate` literals are tokenized onto this scale. Dense sidebar/nav chrome keeps its sub-14px px literals — that is intentional UI density, not reading prose, and was deliberately left untokenized.
v0.6.1patch

Updated dependencies [8947ff1] - @wabbit/tome-ui@0.9.2 - @wabbit/tome-lms@0.6.1

  • Updated dependencies [8947ff1] - @wabbit/tome-ui@0.9.2 - @wabbit/tome-lms@0.6.1
v0.6.0minor

56fc8d5: CourseShell now derives **real** lesson completion + access state. Two changes, both surfacing through `<CourseShell>`: **1. Completion state (`completedLessonIds`).** `MarkCompleteButton`'s "Completed" pill and `CurriculumSidebar`'s per-lesson completion markers never flipped — both derived completion from `enrollment.completedLessons`, a field that **does not exist** on the `CourseEnrollment` schema (the canonical source is the global `lesson-completions` collection). Fix: - New optional `completedLessonIds?: string[]` prop on `<CourseShell>` (`CourseShellRootProps`) and a required `completedLessonIds: ReadonlySet<string>` on `CourseShellContextValue`. Resolve it server-side via `@wabbit/tome-lms#getCompletedLessonIds` and pass it in. - `CourseShellRoot` merges the server-authoritative set with an in-session **optimistic** overlay, so the pill/marker flips immediately on mark-complete and reconciles (prunes) once the server revalidation re-hydrates the prop. - `MarkCompleteButton` + `CurriculumSidebar` now read the context set; the dead `extractCompletedLessonIds` helper (read the non-existent field) is removed. - Back-compat: omit the prop and completion markers simply never flip — the prior behavior, no errors. **2. Access state (`canAccessCurrentLesson`) — BEHAVIOR CHANGE.** `CourseShellRoot` shipped a Wave-2a stub hardcoding `canAccessCurrentLesson = true`, so `VisibilityGate`/`PrerequisiteGate` never actually gated. It now calls the existing pure `deriveAccessState()` (visibility + enrollment + tier + expiry precedence, RSC-safe, mirrors `@wabbit/tome-lms/guards#canAccessLesson`). Prerequisite gating stays disabled (empty list) until a resolver feeds the shell — additive, tracked separately. Consumers that relied on the stub's "everything accessible" behavior will now see locked/preview lessons actually gate. Verify any server-side paywall (e.g. body-stripping) still composes correctly with the now-live client gate.

  • 56fc8d5: CourseShell now derives **real** lesson completion + access state. Two changes, both surfacing through `<CourseShell>`: **1. Completion state (`completedLessonIds`).** `MarkCompleteButton`'s "Completed" pill and `CurriculumSidebar`'s per-lesson completion markers never flipped — both derived completion from `enrollment.completedLessons`, a field that **does not exist** on the `CourseEnrollment` schema (the canonical source is the global `lesson-completions` collection). Fix: - New optional `completedLessonIds?: string[]` prop on `<CourseShell>` (`CourseShellRootProps`) and a required `completedLessonIds: ReadonlySet<string>` on `CourseShellContextValue`. Resolve it server-side via `@wabbit/tome-lms#getCompletedLessonIds` and pass it in. - `CourseShellRoot` merges the server-authoritative set with an in-session **optimistic** overlay, so the pill/marker flips immediately on mark-complete and reconciles (prunes) once the server revalidation re-hydrates the prop. - `MarkCompleteButton` + `CurriculumSidebar` now read the context set; the dead `extractCompletedLessonIds` helper (read the non-existent field) is removed. - Back-compat: omit the prop and completion markers simply never flip — the prior behavior, no errors. **2. Access state (`canAccessCurrentLesson`) — BEHAVIOR CHANGE.** `CourseShellRoot` shipped a Wave-2a stub hardcoding `canAccessCurrentLesson = true`, so `VisibilityGate`/`PrerequisiteGate` never actually gated. It now calls the existing pure `deriveAccessState()` (visibility + enrollment + tier + expiry precedence, RSC-safe, mirrors `@wabbit/tome-lms/guards#canAccessLesson`). Prerequisite gating stays disabled (empty list) until a resolver feeds the shell — additive, tracked separately. Consumers that relied on the stub's "everything accessible" behavior will now see locked/preview lessons actually gate. Verify any server-side paywall (e.g. body-stripping) still composes correctly with the now-live client gate.
  • Updated dependencies [56fc8d5] - @wabbit/tome-lms@0.6.0
v0.4.8patch

Updated dependencies [0b2a1d6] - @wabbit/tome-ui@0.8.3

  • Updated dependencies [0b2a1d6] - @wabbit/tome-ui@0.8.3
v0.4.7patch

Updated dependencies [4225e9f] - @wabbit/tome-ui@0.8.2

  • Updated dependencies [4225e9f] - @wabbit/tome-ui@0.8.2
v0.4.6patch

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

  • Updated dependencies [1d90b24] - @wabbit/tome-ui@0.6.1
v0.3.0minor

**lms-ui — CSS Modules → plain CSS rename (minor, consumer-visible).** `packages/lms-ui/src/components/*/index.module.css` renamed to `index.css` across 30+ components; exports map in `package.json` updated to match (`./components/*` now point to `index.css` under `dist/`). Consumers switch from `import styles from './index.module.css'` to side-effect `import './index.css'`. The previous layout was rejected by Next.js because the CSS used attribute-based global selectors (`[data-layout="three-column"]`), which CSS Modules flag as non-pure. This rename unblocks cross-package CSS `@import` from consumer barrels (e.g. a consumer's `src/styles/tome-lms-ui.css`). Also bumps the build script to add `NODE_OPTIONS=--max-old-space-size=8192` (DTS was OOM'ing against the peer type graph) and adds `cross-env` as a devDep. **admin — Payload 3.x entrypoint alignment + Turbopack cmdk fix (patch).** `DefaultCommandRegistrar` was split: the Payload-aware variant lives in a new `PayloadDefaultCommandRegistrar.tsx` with a static ESM import of `@payloadcms/ui`. Root cause: Next 15 Turbopack's CJS-of-ESM interop returned `useConfig` as not-a-function under the prior `require('@payloadcms/ui')` lazy-load path. Edit/List/Nav entrypoints now render `<DefaultEditView>` and siblings with `DocumentViewClientProps`, matching Payload 3.x's full-replacement slot contract (the prior HOC shape assumed `children` that Payload never delivered). No public API surface changes.

  • **lms-ui — CSS Modules → plain CSS rename (minor, consumer-visible).** `packages/lms-ui/src/components/*/index.module.css` renamed to `index.css` across 30+ components; exports map in `package.json` updated to match (`./components/*` now point to `index.css` under `dist/`). Consumers switch from `import styles from './index.module.css'` to side-effect `import './index.css'`. The previous layout was rejected by Next.js because the CSS used attribute-based global selectors (`[data-layout="three-column"]`), which CSS Modules flag as non-pure. This rename unblocks cross-package CSS `@import` from consumer barrels (e.g. a consumer's `src/styles/tome-lms-ui.css`). Also bumps the build script to add `NODE_OPTIONS=--max-old-space-size=8192` (DTS was OOM'ing against the peer type graph) and adds `cross-env` as a devDep. **admin — Payload 3.x entrypoint alignment + Turbopack cmdk fix (patch).** `DefaultCommandRegistrar` was split: the Payload-aware variant lives in a new `PayloadDefaultCommandRegistrar.tsx` with a static ESM import of `@payloadcms/ui`. Root cause: Next 15 Turbopack's CJS-of-ESM interop returned `useConfig` as not-a-function under the prior `require('@payloadcms/ui')` lazy-load path. Edit/List/Nav entrypoints now render `<DefaultEditView>` and siblings with `DocumentViewClientProps`, matching Payload 3.x's full-replacement slot contract (the prior HOC shape assumed `children` that Payload never delivered). No public API surface changes.
v0.2.0minor

Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

  • Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages. Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort: - `@wabbit/tome-core` 0.1.0 → 0.2.0 - `@wabbit/tome-ui` 0.2.0 → 0.3.0 - `@wabbit/tome-motion` 0.1.0 → 0.2.0 - `@wabbit/tome-lms` 0.1.0 → 0.2.0 - `@wabbit/tome-lms-ui` 0.1.0 → 0.2.0 - `@wabbit/tome-blocks-core` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-extras` 0.1.0 → 0.2.0 (via sprint-3) - `@wabbit/tome-blocks-marketing-starter` 0.1.0 → 0.2.0 (via sprint-3) All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.
  • Updated dependencies - @wabbit/tome-ui@0.3.0 - @wabbit/tome-lms@0.2.0

Gamification

v0.3.6
v0.3.6patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.3.5patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.4patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.3.3patch

c8827e7: Internal refactor: collection slugs are now typed through one package-internal helper instead of inline casts scattered across the source. No API or behavior change.

  • c8827e7: Internal refactor: collection slugs are now typed through one package-internal helper instead of inline casts scattered across the source. No API or behavior change.
  • bcdf9e5: The root barrel now loads under plain Node (Payload CLI), and the award utilities honor renamed collections. The root barrel no longer evaluates `server-only`, so a `payload.config.ts` that imports this package loads under plain Node (Payload CLI `generate:types`, `migrate`). `awardPoints` and `checkAndAwardBadges` now honor renamed collections: both take an optional trailing `slugs` argument (`GamificationSlugs`), and the copies on `createGamificationLayer(config).utilities` are bound to the config's `pointsSlug`/`badgesSlug`/`achievementsSlug`. `./server` is unchanged and still guarded.
v0.3.2patch

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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.

  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
v0.3.1patch

Wave 3 LMS absorbs + platform fixes. lms: certificate-helper enum repairs (valid vs active — verification could never succeed), own-record access resolves MEMBER id, effectiveAwardStatus predicates, award-chain gate + converging reconciler job, certificate expiry sweep (fixes the stalled T-7/T-0 progression), instructorRoles via rolesSlug knob, createdBy/owner authority split, prerequisiteStrictness (G1), ojt_signoff lessonType with approveOJT guard enforced, packaging guards wired. gamification: dist ships extensioned specifiers (raw-Node loadable; lms barrel dependency).

  • Wave 3 LMS absorbs + platform fixes. lms: certificate-helper enum repairs (valid vs active — verification could never succeed), own-record access resolves MEMBER id, effectiveAwardStatus predicates, award-chain gate + converging reconciler job, certificate expiry sweep (fixes the stalled T-7/T-0 progression), instructorRoles via rolesSlug knob, createdBy/owner authority split, prerequisiteStrictness (G1), ojt_signoff lessonType with approveOJT guard enforced, packaging guards wired. gamification: dist ships extensioned specifiers (raw-Node loadable; lms barrel dependency).
v0.3.0minor

68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognizes the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behavior change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.

  • 68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognizes the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behavior change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.
v0.2.1patch

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: 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.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).

Org

v0.19.2
v0.19.2patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v0.19.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.19.0minor

da3e1d5: **BREAKING:** Every org collection now reads org members only by default (`orgMemberRead`); a signed-in account with no member row reads nothing. Before, each collection read core's `authenticated`: any signed-in account read every row. That was fine on a members' portal, where every account belonged to a member, but on a site with public sign-up it handed every roster, squad, team, position, award presentation, kudos, document, event and delegated-authority row to a stranger with an account. The new default, `orgMemberRead`, answers true for staff, for a holder of the collection's own write permissions, and for an account with a row in the member collection; false for everyone else, anonymous or signed in. It is always a boolean, never a filter. **Migration:** nothing to do on a site where every account has a member row. A site that relies on any-signed-in reads passes `access: { read: authenticated }` on the factory, or on the layer's per-collection `access` map; a consumer-supplied `access.read` wins exactly as before. A site with a stricter notion of membership passes `access: { read: orgMemberRead({ memberSlug, isMember }) }` and keeps the staff and manager rules. The Member collection now carries an explicit `read` (the same rule) instead of relying on Payload's signed-in default. `createEventSlotCollection`, `createAwardCollection`, `createMedalCollection` and `createRibbonCollection` gain a `memberSlug` option so the lookup follows a renamed member collection. The opt-in public-read rules are unchanged. `isOrgStaffOrManager` (staff or a listed permission, no membership) is exported alongside `isOrgReader`.

  • da3e1d5: **BREAKING:** Every org collection now reads org members only by default (`orgMemberRead`); a signed-in account with no member row reads nothing. Before, each collection read core's `authenticated`: any signed-in account read every row. That was fine on a members' portal, where every account belonged to a member, but on a site with public sign-up it handed every roster, squad, team, position, award presentation, kudos, document, event and delegated-authority row to a stranger with an account. The new default, `orgMemberRead`, answers true for staff, for a holder of the collection's own write permissions, and for an account with a row in the member collection; false for everyone else, anonymous or signed in. It is always a boolean, never a filter. **Migration:** nothing to do on a site where every account has a member row. A site that relies on any-signed-in reads passes `access: { read: authenticated }` on the factory, or on the layer's per-collection `access` map; a consumer-supplied `access.read` wins exactly as before. A site with a stricter notion of membership passes `access: { read: orgMemberRead({ memberSlug, isMember }) }` and keeps the staff and manager rules. The Member collection now carries an explicit `read` (the same rule) instead of relying on Payload's signed-in default. `createEventSlotCollection`, `createAwardCollection`, `createMedalCollection` and `createRibbonCollection` gain a `memberSlug` option so the lookup follows a renamed member collection. The opt-in public-read rules are unchanged. `isOrgStaffOrManager` (staff or a listed permission, no membership) is exported alongside `isOrgReader`.
v0.18.0minor

3068bd5: `createOrgLayer` takes an `events` group, so a one-call site can rename event types, swap status wording, replace the location field and add fields. The six keys are `eventTypeOptions`, `locationField`, `statusOptions`, `statusDefault`, `extraFields` and `extraFieldsAfter`, the same keys `createEventCollection` already takes; a site that builds that collection itself passes them there directly. Each is forwarded only when set, the other twelve collections are never touched, and a layer built without the group is unchanged. The group's type is exported as `OrgLayerEventsConfig`. On a SQL database a new field is a new column, so regenerate your migration if you add one.

  • 3068bd5: `createOrgLayer` takes an `events` group, so a one-call site can rename event types, swap status wording, replace the location field and add fields. The six keys are `eventTypeOptions`, `locationField`, `statusOptions`, `statusDefault`, `extraFields` and `extraFieldsAfter`, the same keys `createEventCollection` already takes; a site that builds that collection itself passes them there directly. Each is forwarded only when set, the other twelve collections are never touched, and a layer built without the group is unchanged. The group's type is exported as `OrgLayerEventsConfig`. On a SQL database a new field is a new column, so regenerate your migration if you add one.
v0.17.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.17.0minor

866df58: Documents can now be read in public, opt-in, and a public register of them cannot leak an internal one. `createOrgLayer({ publicRead: { documents: true } })` (or `createDocumentCollection({ publicRead })`) adds a `visibility` select that defaults to **internal**, so turning the option on publishes nothing until an editor marks a document public. An anonymous visitor then reads only documents that are public, approved or published (archived and draft are never public) and not restricted to a division or team. `author` and `collaborators` are hidden from anonymous readers and cannot be used as filters. An explicit `access.read` still wins. New exports: `buildPublicDocumentsWhere`, `publicDocumentsRead`, `PublicDocumentsReadOptions`, `PublicDocumentsCollectionOptions`, `DEFAULT_PUBLIC_DOCUMENT_GUARDED_FIELDS`, `DEFAULT_PUBLIC_DOCUMENT_STATUSES`. A public register (an `archive` registration) should pass `buildPublicDocumentsWhere()` as its fixed `where`: a member's read is unrestricted, so access alone would show internal documents to every member. `documents: { files: true }` (or `files` on the factory) adds an upload field after `content` (default name `file`, relation to `mediaCollection` unless `uploadCollection` is set). Uploads are public by URL, so while public read is on the field refuses a file on a document whose visibility is not public, on create and when a document that holds a file is switched to internal; `allowOnInternal: true` lifts the rule for a site with a private upload collection. `documents: { categoryOptions }` (or `categoryOptions` on the factory) extends or replaces the `category` vocabulary; `DEFAULT_DOCUMENT_CATEGORY_OPTIONS` is exported and equals today's eleven values. With none of these options the collection is byte-identical to before: no `visibility` or `file` field, `read: authenticated`, the same `category` options (a golden cut from the pre-change source holds this). On a SQL database the `visibility` and `file` fields are new columns only on sites that opt in, which then generate a migration. - **Signed in is not the same as a member.** With public read on, the full document read (every row, `author` and `collaborators` readable and filterable) goes only to staff (a role with `accessType: 'admin'`), document managers (`MANAGE_DOCUMENTS` or `DELETE_DOCUMENTS`) and org members (a row in the member collection whose `user` is the signed-in user). Every other signed-in account, such as a customer or donor created by a public sign-up, reads exactly what an anonymous visitor reads: approved or published, public, unrestricted documents, with `author` and `collaborators` hidden and refused as filters. New exports: `isOrgDocumentReader`, `documentReaderFieldRead`, `DocumentReaderOptions`; `publicDocumentsRead` also takes `memberSlug` and `memberUserField`, and `guardFieldsForSignedIn` takes an optional field guard. The layer has no `restrictedTo*` rule for signed-in readers, so members still read restricted rows, as they did before the option. With the option off nothing changes: `read: authenticated`, held by the default-collection golden. - **BREAKING:** for sites that turned on `publicRead.events`, `.divisions` or `.members` in 0.16.0, signed-in accounts that are not staff, permission holders or org members now read only the public set (see the next bullet). A site that relied on every signed-in account seeing the full read must pass `permissions` to the rule or set an explicit `access.read`. - **Security fix for the 0.16.0 public read of events, divisions and members: signed in is not the same as a member there either.** With `publicRead.events`, `.divisions` or `.members` on, any signed-in account got the full read, so on a site with public sign-up a new customer account could read members-only and restricted events (with `briefing`, organizer and co-hosts), disbanded divisions, and classified members with `user`, `reportsTo`, position, timezone, region and last-promoted date. The full read now goes only to staff (`accessType: 'admin'`), holders of the collection's own write permissions (events: `CREATE_EVENTS`, `EDIT_EVENTS`, `DELETE_EVENTS`, `MANAGE_EVENTS`; divisions: `EDIT_STRUCTURE`, `DELETE_STRUCTURE`, `MANAGE_STRUCTURE`; members: `INDUCT_MEMBERS`, `MANAGE_UNIT_ROSTER`, `MANAGE_STRUCTURE`) and org members. Every other signed-in account reads exactly what an anonymous visitor reads, with the guarded fields hidden and refused as filters. A site that wants some other signed-in audience to see more can pass `permissions` to the rule or an explicit `access.read`. New exports: `isOrgReader`, `orgReaderFieldRead`, `resolveOrgReaderOptions`, `ORG_READER_PERMISSIONS`, `OrgReaderOptions`; `publicEventsRead`, `publicDivisionsRead` and `publicMembersRead` take `memberSlug`, `memberUserField` and `permissions`. With the options off nothing changes (`read: authenticated` on events and divisions, Payload's signed-in default on members).

  • 866df58: Documents can now be read in public, opt-in, and a public register of them cannot leak an internal one. `createOrgLayer({ publicRead: { documents: true } })` (or `createDocumentCollection({ publicRead })`) adds a `visibility` select that defaults to **internal**, so turning the option on publishes nothing until an editor marks a document public. An anonymous visitor then reads only documents that are public, approved or published (archived and draft are never public) and not restricted to a division or team. `author` and `collaborators` are hidden from anonymous readers and cannot be used as filters. An explicit `access.read` still wins. New exports: `buildPublicDocumentsWhere`, `publicDocumentsRead`, `PublicDocumentsReadOptions`, `PublicDocumentsCollectionOptions`, `DEFAULT_PUBLIC_DOCUMENT_GUARDED_FIELDS`, `DEFAULT_PUBLIC_DOCUMENT_STATUSES`. A public register (an `archive` registration) should pass `buildPublicDocumentsWhere()` as its fixed `where`: a member's read is unrestricted, so access alone would show internal documents to every member. `documents: { files: true }` (or `files` on the factory) adds an upload field after `content` (default name `file`, relation to `mediaCollection` unless `uploadCollection` is set). Uploads are public by URL, so while public read is on the field refuses a file on a document whose visibility is not public, on create and when a document that holds a file is switched to internal; `allowOnInternal: true` lifts the rule for a site with a private upload collection. `documents: { categoryOptions }` (or `categoryOptions` on the factory) extends or replaces the `category` vocabulary; `DEFAULT_DOCUMENT_CATEGORY_OPTIONS` is exported and equals today's eleven values. With none of these options the collection is byte-identical to before: no `visibility` or `file` field, `read: authenticated`, the same `category` options (a golden cut from the pre-change source holds this). On a SQL database the `visibility` and `file` fields are new columns only on sites that opt in, which then generate a migration. - **Signed in is not the same as a member.** With public read on, the full document read (every row, `author` and `collaborators` readable and filterable) goes only to staff (a role with `accessType: 'admin'`), document managers (`MANAGE_DOCUMENTS` or `DELETE_DOCUMENTS`) and org members (a row in the member collection whose `user` is the signed-in user). Every other signed-in account, such as a customer or donor created by a public sign-up, reads exactly what an anonymous visitor reads: approved or published, public, unrestricted documents, with `author` and `collaborators` hidden and refused as filters. New exports: `isOrgDocumentReader`, `documentReaderFieldRead`, `DocumentReaderOptions`; `publicDocumentsRead` also takes `memberSlug` and `memberUserField`, and `guardFieldsForSignedIn` takes an optional field guard. The layer has no `restrictedTo*` rule for signed-in readers, so members still read restricted rows, as they did before the option. With the option off nothing changes: `read: authenticated`, held by the default-collection golden. - **BREAKING:** for sites that turned on `publicRead.events`, `.divisions` or `.members` in 0.16.0, signed-in accounts that are not staff, permission holders or org members now read only the public set (see the next bullet). A site that relied on every signed-in account seeing the full read must pass `permissions` to the rule or set an explicit `access.read`. - **Security fix for the 0.16.0 public read of events, divisions and members: signed in is not the same as a member there either.** With `publicRead.events`, `.divisions` or `.members` on, any signed-in account got the full read, so on a site with public sign-up a new customer account could read members-only and restricted events (with `briefing`, organizer and co-hosts), disbanded divisions, and classified members with `user`, `reportsTo`, position, timezone, region and last-promoted date. The full read now goes only to staff (`accessType: 'admin'`), holders of the collection's own write permissions (events: `CREATE_EVENTS`, `EDIT_EVENTS`, `DELETE_EVENTS`, `MANAGE_EVENTS`; divisions: `EDIT_STRUCTURE`, `DELETE_STRUCTURE`, `MANAGE_STRUCTURE`; members: `INDUCT_MEMBERS`, `MANAGE_UNIT_ROSTER`, `MANAGE_STRUCTURE`) and org members. Every other signed-in account reads exactly what an anonymous visitor reads, with the guarded fields hidden and refused as filters. A site that wants some other signed-in audience to see more can pass `permissions` to the rule or an explicit `access.read`. New exports: `isOrgReader`, `orgReaderFieldRead`, `resolveOrgReaderOptions`, `ORG_READER_PERMISSIONS`, `OrgReaderOptions`; `publicEventsRead`, `publicDivisionsRead` and `publicMembersRead` take `memberSlug`, `memberUserField` and `permissions`. With the options off nothing changes (`read: authenticated` on events and divisions, Payload's signed-in default on members).
v0.16.0minor

3d59ade: `createOrgLayer` takes `includeWaitlistCount` and passes it to the events collection. `createEventCollection` already had the option, which adds a read-only `waitlistCount` counter right after `attendeeCount`, but `createOrgLayer` never passed it on, so a site on the one-call layer could not turn it on. `createOrgLayer({ includeWaitlistCount: true })` now does. It is off by default, and a layer built without it (or with it `false`) is structurally identical to before. On a SQL database the counter is a new column, so a site that turns it on and uses migrations generates one. Purely additive: no peer is added or tightened, and nothing changes for a site that does not set it.

  • 3d59ade: `createOrgLayer` takes `includeWaitlistCount` and passes it to the events collection. `createEventCollection` already had the option, which adds a read-only `waitlistCount` counter right after `attendeeCount`, but `createOrgLayer` never passed it on, so a site on the one-call layer could not turn it on. `createOrgLayer({ includeWaitlistCount: true })` now does. It is off by default, and a layer built without it (or with it `false`) is structurally identical to before. On a SQL database the counter is a new column, so a site that turns it on and uses migrations generates one. Purely additive: no peer is added or tightened, and nothing changes for a site that does not set it.
v0.15.0minor

f606889: Security/visibility fix: new opt-in `publicRead` lets a public site show public events, active divisions and public members to anonymous visitors without leaking private rows or fields. `createOrgLayer({ publicRead: { events, divisions, members } })`, or `publicRead` on `createEventCollection` / `createDivisionCollection` / `createMemberCollection`, applies the new `publicEventsRead`, `publicDivisionsRead` and `publicMembersRead` rules. Signed-in users keep today's read. Anonymous visitors get a database-side filter built from each collection's own fields: events whose visibility is `public` and whose status is not `draft`; divisions whose status is `active`; members whose directory visibility is `public` and whose standing is `active`. Field-level `access.read` hides `briefing`, the organizer and the co-hosts on events, and `user`, `reportsTo`, `position`, `timezone`, `region` and `lastPromoted` on members (`extraGuardedFields` adds more). Use this instead of reading with `overrideAccess: true` from a public page, which ignores visibility and exposes members-only events. Nothing changes for a site that does not opt in, and an explicit `access.read` still wins.

  • f606889: Security/visibility fix: new opt-in `publicRead` lets a public site show public events, active divisions and public members to anonymous visitors without leaking private rows or fields. `createOrgLayer({ publicRead: { events, divisions, members } })`, or `publicRead` on `createEventCollection` / `createDivisionCollection` / `createMemberCollection`, applies the new `publicEventsRead`, `publicDivisionsRead` and `publicMembersRead` rules. Signed-in users keep today's read. Anonymous visitors get a database-side filter built from each collection's own fields: events whose visibility is `public` and whose status is not `draft`; divisions whose status is `active`; members whose directory visibility is `public` and whose standing is `active`. Field-level `access.read` hides `briefing`, the organizer and the co-hosts on events, and `user`, `reportsTo`, `position`, `timezone`, `region` and `lastPromoted` on members (`extraGuardedFields` adds more). Use this instead of reading with `overrideAccess: true` from a public page, which ignores visibility and exposes members-only events. Nothing changes for a site that does not opt in, and an explicit `access.read` still wins.
v0.14.6patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.14.5patch

bfe6c08: `createRibbonCollection({ certificationSlug: false })` omits the `eligibility.requiredCertification` relationship. That relationship points at an LMS-owned collection. On a site with no LMS installed it failed Payload's config validation at startup, and nothing short of dropping the Ribbon collection could fix that. The default (`'certifications'`) and custom slugs are unchanged.

  • bfe6c08: `createRibbonCollection({ certificationSlug: false })` omits the `eligibility.requiredCertification` relationship. That relationship points at an LMS-owned collection. On a site with no LMS installed it failed Payload's config validation at startup, and nothing short of dropping the Ribbon collection could fix that. The default (`'certifications'`) and custom slugs are unchanged.
v0.14.4patch

d3ad4ce: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.

  • d3ad4ce: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behavior change.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.14.3patch

30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.

  • 30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.
  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`. `extractRelationshipId` and `extractRelationshipIds` (public) are kept as deprecated delegates to core, with identical semantics.
v0.14.2patch

8352ba5: Fix `createAwardPresentationCollection`'s recipient-count recount resolving the wrong catalog collection slug, which silently broke `recipientCount` on every write for a consumer using the factory's default catalog slugs. **Root cause:** `createRecipientCountSyncHook`'s own default `resolveTarget` treats the discriminator FIELD VALUE (`awardType`: `'awards'`/`'medals'`/`'ribbons'`) as the target collection slug — a reasonable default for a generic hook, but `createAwardPresentationCollection` names its catalog collections `awardSlug`/`medalSlug`/`ribbonSlug` (default `'org-awards'`/`'org-medals'`/`'org-ribbons'`, each independently overridable), never the bare type value. Every `afterChange`/`afterDelete` on an AwardPresentation therefore attempted `payload.update({ collection: 'awards', ... })` against a collection that doesn't exist, logging `"recipientCount recount failed for awards id …: The collection with slug awards can't be found"` and leaving `recipientCount` frozen (found on tome-starter 2026-09-12, where the consumer had already carried a `resolveTarget`/`countWhere` override as a bandaid — this patch replaces that at the root). **The fix:** `createAwardPresentationCollection` now derives its own `resolveTarget` (discriminator type → the factory's resolved `awardSlug`/`medalSlug`/`ribbonSlug`) and matching `countWhere` (reversing the resolved slug back to the discriminator's type value, so the presentation-collection where clause still compares `awardType` to `'awards'`, never to `'org-awards'`) from its own slug options, passed as the base `recipientCountSync` config BEFORE `...config.recipientCountSync` — so a consumer's own explicit `resolveTarget`/`countWhere` override still wins unchanged. **Backward compatibility:** a consumer who already worked around this (a custom `recipientCountSync.resolveTarget`/`countWhere`) is unaffected — their override still takes precedence. A consumer relying on the (broken) default now gets a working recount instead of a silently-failing one; no API shape changed.

  • 8352ba5: Fix `createAwardPresentationCollection`'s recipient-count recount resolving the wrong catalog collection slug, which silently broke `recipientCount` on every write for a consumer using the factory's default catalog slugs. **Root cause:** `createRecipientCountSyncHook`'s own default `resolveTarget` treats the discriminator FIELD VALUE (`awardType`: `'awards'`/`'medals'`/`'ribbons'`) as the target collection slug — a reasonable default for a generic hook, but `createAwardPresentationCollection` names its catalog collections `awardSlug`/`medalSlug`/`ribbonSlug` (default `'org-awards'`/`'org-medals'`/`'org-ribbons'`, each independently overridable), never the bare type value. Every `afterChange`/`afterDelete` on an AwardPresentation therefore attempted `payload.update({ collection: 'awards', ... })` against a collection that doesn't exist, logging `"recipientCount recount failed for awards id …: The collection with slug awards can't be found"` and leaving `recipientCount` frozen (found on tome-starter 2026-09-12, where the consumer had already carried a `resolveTarget`/`countWhere` override as a bandaid — this patch replaces that at the root). **The fix:** `createAwardPresentationCollection` now derives its own `resolveTarget` (discriminator type → the factory's resolved `awardSlug`/`medalSlug`/`ribbonSlug`) and matching `countWhere` (reversing the resolved slug back to the discriminator's type value, so the presentation-collection where clause still compares `awardType` to `'awards'`, never to `'org-awards'`) from its own slug options, passed as the base `recipientCountSync` config BEFORE `...config.recipientCountSync` — so a consumer's own explicit `resolveTarget`/`countWhere` override still wins unchanged. **Backward compatibility:** a consumer who already worked around this (a custom `recipientCountSync.resolveTarget`/`countWhere`) is unaffected — their override still takes precedence. A consumer relying on the (broken) default now gets a working recount instead of a silently-failing one; no API shape changed.
v0.14.1patch

8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.

  • 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
  • ce3d12d: Collapse the package's three slug-field builders and three slug hooks onto one `src/slugField.ts` (2026-09-01 sale-readiness audit §5.1, T3(g)). What there was: `resolveSlugField.ts` (`buildDefaultSlugField` + `autoSlugHook`, used by Division, Team, Rank, Event, Document, Wing, Unit); `collections/recognition/catalogItemFields.ts` (`buildCatalogSlugField` + `catalogItemSlugifyHook`, used by Award, Medal, Ribbon, and carrying its own hand-rolled slugify REGEX — one of the four the audit counted repo-wide, which is why the same title produced different anchors depending on which collection you were in); and a private third pair inside `Memorial.ts`, written only because the other two typed `sourceField` to a fixed `'name' | 'title' | 'displayName'` union while Memorial's display field is `callsign`. What there is: `src/slugField.ts` — `orgSlugHook`, `buildOrgSlugField`, `resolveOrgSlugField` — taking a plain `string` source field (so Memorial's reason to fork is gone) and a `variant`. **Two field shapes survive on purpose, because both are stored shapes:** `'org-default'` (unique + index + required + sidebar; re-derives whenever the slug is empty and re-formats an explicitly-set value) and `'catalog-item'` (required + unique only; CREATE-ONCE, so an admin who renames an Award after publication keeps its original slug and anything keyed on it stays valid). This is a consolidation, not a unification. Every public name stays exported and delegates, carrying `@deprecated` pointing at the new one: `resolveSlugField`, `buildDefaultSlugField`, `buildCatalogSlugField`, `catalogItemSlugifyHook`. Nothing is removed, and `src/index.ts` exports no new names — the new module is internal, so this is a patch. **One deliberate behavior change, stated rather than buried.** The catalog trio's hook now uses core's `formatSlug` (as the org-wide and Memorial hooks already did) instead of its own `/[^a-z0-9]+/g` regex. The regexes agree on everything the audit cared about — case, whitespace runs, punctuation, edge hyphens, accented characters — except one: the old one mapped `_` to `-`, and `formatSlug` KEEPS `_` (it is a word character). The hook is create-only, so **no stored slug moves**; only Awards, Medals and Ribbons created after this change whose name contains an underscore will slug differently. `tests/slug-field-consolidation.test.ts` pins that case explicitly so the difference is a decision on the record rather than something rediscovered from a support ticket, and pins that a title without an underscore now produces the SAME anchor as the org-wide hook — which was the point. That test file is also the characterization proof: it was written from the pre-change source, run green against the three separate builders, and run green again after. It pins each caller's emitted field (hooks stripped, since a moved function is a new object) and each caller's three-state override contract — `undefined` = the built-in field, `false` = no field, `Field[]` = the consumer's fields verbatim and by array identity. org's suite: 43 files, 873 tests, green.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behavior changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
v0.14.0minor

d1b7ea0: Thread the fieldShape pipeline, `decidedByRelationTo`, notification fan-out, and checklist item field names through the recruiting factories (R2-I1.1) — the seam gaps a consumer adapter's correct STOPs found and R2-I2 confirmed empirically (`createApplicationCollection({ omitFields: [...] })` silently ignored it). **Fix — fieldShape pipeline now threaded through Application/JoinRequest/OnboardingRecord.** None of the three recruiting factories applied `omitFields`/`fieldOverrides`/`fieldOrder`, and none accepted `extraFieldsAfter` (interior splice) — `extraFields` only ever appended at the tail. All three now run the same wiring order `createAwardCollection` established (`extraFields` via `insertFieldsAfter` → `applyFieldOverrides` → `applyOmitFields` → `applyFieldOrder`). Zero-config output is byte-identical; every new key is a no-op when omitted. **Fix — JoinRequest `decidedByRelationTo`.** `decidedBy` unconditionally shared `requester`'s relation (`memberSlug`) with no way to point it elsewhere. A production consumer's real shape needs `decidedBy` on `'users'`, not the member collection. New `decidedByRelationTo` config key, defaulting to `memberSlug` (byte-identical back-compat). **New — `adminOverrides` on all three recruiting factories.** `useAsTitle`/`defaultColumns`/`group`/`description` were hardcoded in each factory's return statement, reachable nowhere except `adminGroup` (which only ever reaches `group`). This is a **new convention**, not an existing house mechanism being wired through — no other factory in `@wabbit/tome-org` exposes more than `adminGroup` either (`Award`/`EventSlot` included). Scoped to the three recruiting factories per this increment's brief, not a silent package-wide fix. `adminOverrides` shallow-merges last, so `adminOverrides.group` wins over `adminGroup` when both are given. **Fix — `createPendingStatusNotificationHook` gains `buildNotifications` (plural).** The singular `buildNotification` can express only one notification row per open transition; a production consumer needs N independent rows — one per resolved reviewer/officer. `buildNotifications` returns an array; each entry is created independently, deduped by its own key (an entry may set `dedupKey` explicitly — e.g. per-recipient — or falls back to `` `${baseDedupKey}:${index}` ``), and one entry's create failure is reported via `onSyncFailure` without blocking the rest. Back-compat: `buildNotification` alone still works; `buildNotifications` wins when both are given. The hook now throws at creation time if neither is provided (previously `buildNotification` was a required config key — this is the same requirement, restated to cover the new either/or). **Fix — sparse-checklist item field names.** `buildSparseChecklistGroupField`'s `completions[]` rows hardcoded `itemKey`/`completedAt`/`completedBy` with no rename seam — the one holdout among this package's field-producing builders. New `itemFieldNames` (on the builder directly, and per-entry on `createOnboardingRecordCollection`'s `checklistGroups`) renames them; a production consumer needs `templateItemId`/`checkedAt`/`checkedBy`. Omitted keys keep the default names. All five gaps ship with red-first tests (each proven failing against pre-fix behavior) plus zero-config byte-identical pins for all three factories. Full `@wabbit/tome-org` suite: 849/849 (814 baseline + 35 new).

  • d1b7ea0: Thread the fieldShape pipeline, `decidedByRelationTo`, notification fan-out, and checklist item field names through the recruiting factories (R2-I1.1) — the seam gaps a consumer adapter's correct STOPs found and R2-I2 confirmed empirically (`createApplicationCollection({ omitFields: [...] })` silently ignored it). **Fix — fieldShape pipeline now threaded through Application/JoinRequest/OnboardingRecord.** None of the three recruiting factories applied `omitFields`/`fieldOverrides`/`fieldOrder`, and none accepted `extraFieldsAfter` (interior splice) — `extraFields` only ever appended at the tail. All three now run the same wiring order `createAwardCollection` established (`extraFields` via `insertFieldsAfter` → `applyFieldOverrides` → `applyOmitFields` → `applyFieldOrder`). Zero-config output is byte-identical; every new key is a no-op when omitted. **Fix — JoinRequest `decidedByRelationTo`.** `decidedBy` unconditionally shared `requester`'s relation (`memberSlug`) with no way to point it elsewhere. A production consumer's real shape needs `decidedBy` on `'users'`, not the member collection. New `decidedByRelationTo` config key, defaulting to `memberSlug` (byte-identical back-compat). **New — `adminOverrides` on all three recruiting factories.** `useAsTitle`/`defaultColumns`/`group`/`description` were hardcoded in each factory's return statement, reachable nowhere except `adminGroup` (which only ever reaches `group`). This is a **new convention**, not an existing house mechanism being wired through — no other factory in `@wabbit/tome-org` exposes more than `adminGroup` either (`Award`/`EventSlot` included). Scoped to the three recruiting factories per this increment's brief, not a silent package-wide fix. `adminOverrides` shallow-merges last, so `adminOverrides.group` wins over `adminGroup` when both are given. **Fix — `createPendingStatusNotificationHook` gains `buildNotifications` (plural).** The singular `buildNotification` can express only one notification row per open transition; a production consumer needs N independent rows — one per resolved reviewer/officer. `buildNotifications` returns an array; each entry is created independently, deduped by its own key (an entry may set `dedupKey` explicitly — e.g. per-recipient — or falls back to `` `${baseDedupKey}:${index}` ``), and one entry's create failure is reported via `onSyncFailure` without blocking the rest. Back-compat: `buildNotification` alone still works; `buildNotifications` wins when both are given. The hook now throws at creation time if neither is provided (previously `buildNotification` was a required config key — this is the same requirement, restated to cover the new either/or). **Fix — sparse-checklist item field names.** `buildSparseChecklistGroupField`'s `completions[]` rows hardcoded `itemKey`/`completedAt`/`completedBy` with no rename seam — the one holdout among this package's field-producing builders. New `itemFieldNames` (on the builder directly, and per-entry on `createOnboardingRecordCollection`'s `checklistGroups`) renames them; a production consumer needs `templateItemId`/`checkedAt`/`checkedBy`. Omitted keys keep the default names. All five gaps ship with red-first tests (each proven failing against pre-fix behavior) plus zero-config byte-identical pins for all three factories. Full `@wabbit/tome-org` suite: 849/849 (814 baseline + 35 new).
v0.13.0minor

6d32747: **New Recruiting cluster — three opt-in collection factories plus three shared hook factories and a sparse-checklist field builder (Recruiting/Onboarding Wave / R2-I1).** `createApplicationCollection` — staged-intake application (`submitted/in-review/accepted/rejected/withdrawn`, generic vocabulary — NOT any one consumer's own status words). `createJoinRequestCollection` — a join request against ANY org node, with `targetRelationTo`/`targetLabel` REQUIRED per the wave's naming directive (no default target — a factory that defaulted to `'teams'` would be smuggling in an opinion about what "join requests" are for). `createOnboardingRecordCollection` — a member's onboarding progress record with two sparse-checklist groups (`intake`/`handoff` by default) and, per the wave plan's own instruction, NO built-in hooks under any configuration (a production consumer's real shape has none). Three factory-generic behaviors absorbed from the R2-I0 coupling map, each opt-in and export-only: - `createGuardDirectTransitionHook` — the STRUCTURE of a direct-approval guard (block a status field from entering a guarded value unless a privilege check passes), with the flag name, error copy, and guarded values 100% config. `createApplicationCollection` accepts it via `guardDirectTransition`. - `createPendingStatusNotificationHook` — the SAME `upsertNotification`/`resolveNotificationsByGroupKey` open/close fan-out both `Applications` and `WingJoinRequests` independently reimplemented byte-for-byte identically at a consumer's integration branch. Built ONCE; both `createApplicationCollection` and `createJoinRequestCollection` accept it via `pendingStatusNotification`. - `createTaskCleanupOnDeleteHook` — a generic afterDelete hook running an injected `resolveTasks` resolver, never rethrowing. `createApplicationCollection` accepts it via `taskCleanupOnDelete`. `buildSparseChecklistGroupField` (`collections/recruiting/shared/sparseChecklistField.ts`) — the sparse-completions-log shape (a `completions[]` array of only the items actually done, template lives elsewhere) the coupling map found used three independent times; `createOnboardingRecordCollection` uses it twice (`intake`/`handoff`). **The approval/assignment contract:** `createJoinRequestCollection` never assigns anything on approval — flipping `status` to an approved value is a plain field write with no afterChange hook reacting to it by default. This reproduces a production consumer's own proven separation (approval is a decision record; a separate handoff step does the actual assignment) as a hard contract, documented in the factory's own header, not an implementation detail a future change could accidentally erase. Three new subpath doors (`./collections/application`, `./collections/joinRequest`, `./collections/onboardingRecord`), each proven server-only-free via a dedicated subpath-loadable test plus the package's `assert-node-loadable.mjs --every-file` gate (196/196 targets pass). `@wabbit/tome-workflow` added as an optional peer + devDependency solely to prove, against the real `claimTransition` primitive, that `JoinRequest`'s `status` field is CAS-compatible — the factory itself has no runtime dependency on the workflow engine. Not wired into `createOrgLayer` (design principle 2). No consumer-specific vocabulary ("wing", rank names) appears in any factory's runtime code — proven by a dedicated test that strips comments/string-literals from each source file and asserts zero "wing" substring remains, plus a consumer-emulation test per factory showing a production consumer's real shape is reachable purely through config injection.

  • 6d32747: **New Recruiting cluster — three opt-in collection factories plus three shared hook factories and a sparse-checklist field builder (Recruiting/Onboarding Wave / R2-I1).** `createApplicationCollection` — staged-intake application (`submitted/in-review/accepted/rejected/withdrawn`, generic vocabulary — NOT any one consumer's own status words). `createJoinRequestCollection` — a join request against ANY org node, with `targetRelationTo`/`targetLabel` REQUIRED per the wave's naming directive (no default target — a factory that defaulted to `'teams'` would be smuggling in an opinion about what "join requests" are for). `createOnboardingRecordCollection` — a member's onboarding progress record with two sparse-checklist groups (`intake`/`handoff` by default) and, per the wave plan's own instruction, NO built-in hooks under any configuration (a production consumer's real shape has none). Three factory-generic behaviors absorbed from the R2-I0 coupling map, each opt-in and export-only: - `createGuardDirectTransitionHook` — the STRUCTURE of a direct-approval guard (block a status field from entering a guarded value unless a privilege check passes), with the flag name, error copy, and guarded values 100% config. `createApplicationCollection` accepts it via `guardDirectTransition`. - `createPendingStatusNotificationHook` — the SAME `upsertNotification`/`resolveNotificationsByGroupKey` open/close fan-out both `Applications` and `WingJoinRequests` independently reimplemented byte-for-byte identically at a consumer's integration branch. Built ONCE; both `createApplicationCollection` and `createJoinRequestCollection` accept it via `pendingStatusNotification`. - `createTaskCleanupOnDeleteHook` — a generic afterDelete hook running an injected `resolveTasks` resolver, never rethrowing. `createApplicationCollection` accepts it via `taskCleanupOnDelete`. `buildSparseChecklistGroupField` (`collections/recruiting/shared/sparseChecklistField.ts`) — the sparse-completions-log shape (a `completions[]` array of only the items actually done, template lives elsewhere) the coupling map found used three independent times; `createOnboardingRecordCollection` uses it twice (`intake`/`handoff`). **The approval/assignment contract:** `createJoinRequestCollection` never assigns anything on approval — flipping `status` to an approved value is a plain field write with no afterChange hook reacting to it by default. This reproduces a production consumer's own proven separation (approval is a decision record; a separate handoff step does the actual assignment) as a hard contract, documented in the factory's own header, not an implementation detail a future change could accidentally erase. Three new subpath doors (`./collections/application`, `./collections/joinRequest`, `./collections/onboardingRecord`), each proven server-only-free via a dedicated subpath-loadable test plus the package's `assert-node-loadable.mjs --every-file` gate (196/196 targets pass). `@wabbit/tome-workflow` added as an optional peer + devDependency solely to prove, against the real `claimTransition` primitive, that `JoinRequest`'s `status` field is CAS-compatible — the factory itself has no runtime dependency on the workflow engine. Not wired into `createOrgLayer` (design principle 2). No consumer-specific vocabulary ("wing", rank names) appears in any factory's runtime code — proven by a dedicated test that strips comments/string-literals from each source file and asserts zero "wing" substring remains, plus a consumer-emulation test per factory showing a production consumer's real shape is reachable purely through config injection.
v0.12.1patch

4c1718a: Fixed a defect where `createRecipientCountSyncHook`'s `recount()` (the recognition `recipientCount` sync attached to `AwardPresentation`'s `afterChange`/`afterDelete`) ran its `payload.count()` against the resolved CATALOG collection (`target.collection` — `'awards'`/`'medals'`/`'ribbons'`) instead of the SOURCE presentation collection the hook is attached to. `countWhere` builds its `where` clause over presentation-only fields (`awardType`, `award_awards`, …), which real Payload rejects against the catalog collection ("The following path cannot be queried"); the hook's own catch swallowed that error into `reportOrgSyncFailure` (logger-only, never throws), so the recount silently never ran in any production-shaped use — `recipientCount` has been permanently stuck wherever it started since this hook shipped in 0.12.0. `recount()` already received `sourceCollection` as a parameter (the presentation collection, from `collection.slug` in both `afterChange` and `afterDelete`'s hook args) but only used it in the failure report. The count call now targets `sourceCollection`; the subsequent `payload.update` is unchanged and still correctly targets `target.collection` — count-from-source, write-to-target is the contract. The existing unit tests asserted the count call's `collection` equaled the catalog collection, which enshrined the bug as expected behavior (the mock never validated the argument against real Payload's path-queryability rules); they now assert the count/update collection asymmetry directly, plus a regression pin using a source-collection slug distinct from every catalog slug so a reintroduced swap cannot pass by coincidence.

  • 4c1718a: Fixed a defect where `createRecipientCountSyncHook`'s `recount()` (the recognition `recipientCount` sync attached to `AwardPresentation`'s `afterChange`/`afterDelete`) ran its `payload.count()` against the resolved CATALOG collection (`target.collection` — `'awards'`/`'medals'`/`'ribbons'`) instead of the SOURCE presentation collection the hook is attached to. `countWhere` builds its `where` clause over presentation-only fields (`awardType`, `award_awards`, …), which real Payload rejects against the catalog collection ("The following path cannot be queried"); the hook's own catch swallowed that error into `reportOrgSyncFailure` (logger-only, never throws), so the recount silently never ran in any production-shaped use — `recipientCount` has been permanently stuck wherever it started since this hook shipped in 0.12.0. `recount()` already received `sourceCollection` as a parameter (the presentation collection, from `collection.slug` in both `afterChange` and `afterDelete`'s hook args) but only used it in the failure report. The count call now targets `sourceCollection`; the subsequent `payload.update` is unchanged and still correctly targets `target.collection` — count-from-source, write-to-target is the contract. The existing unit tests asserted the count call's `collection` equaled the catalog collection, which enshrined the bug as expected behavior (the mock never validated the argument against real Payload's path-queryability rules); they now assert the count/update collection asymmetry directly, plus a regression pin using a source-collection slug distinct from every catalog slug so a reintroduced swap cannot pass by coincidence.
v0.12.0minor

6f1fc50: **New Recognition cluster — six opt-in collection factories, three hook factories, one access factory, and GDPR registration (Recognition Wave / R-I1).** `createAwardCollection`, `createMedalCollection`, `createRibbonCollection` — the catalog trio (formal award/medal/ribbon definitions), sharing a `catalogItemFields.ts` builder set (shared field/slug builders, not a rigid shared array — the three real collections' field orders and eligibility shapes genuinely diverge). `createAwardPresentationCollection` — the "who actually got one" record, wiring `createDisplayNameComposeHook` (compose seam), an opt-in `createAwardedBySelfEnforcementHook` (admin "on behalf of" bypass, non-admin forced to self), and a default-on `createRecipientCountSyncHook` (`payload.count()`, never `find({limit:0})` — the historical N+1 regression this cluster's own pins guard against). `createKudosCollection` — peer-to-peer recognition, `update` access via the new exported `createGiverWindowAccess` factory (originator self-edit within a configurable minute window). `createMemorialCollection` — public "In Memoriam" tributes, published-gated read access. `registerRecognitionGdpr`/`unregisterRecognitionGdpr` (`packages/org/src/collections/recognition/gdpr.ts`) — kudos hard-deletes the erased member's own rows as RECIPIENT while separately null-refing rows where they were the GIVER (one registry slot, two dispositions); award-presentations and memorials retain. Awards/Medals/Ribbons are deliberately never registered — catalog/definition rows carry no member relationship to key an erasure off of. Every factory threads the shared `fieldShape` mechanism (`fieldOverrides`/`fieldOrder`/`omitFields`/`fieldDescriptions`-null-suppress/`extraFields`/`extraFieldsAfter`) and per-verb `access` overrides — no held-out defaults. Discord fan-out and any web-framework cache-tag revalidation stay consumer-side (Wave 9 doctrine): no factory attaches notification hooks; `createRecipientCountSyncHook`'s `onExtraSync` is the composition seam for that coupling instead. **Opt-in only** — nothing in this cluster is wired into `createOrgLayer`; a consumer imports and assembles these six factories directly. `ORG_LAYER_VERSION` is unchanged.

  • 6f1fc50: **New Recognition cluster — six opt-in collection factories, three hook factories, one access factory, and GDPR registration (Recognition Wave / R-I1).** `createAwardCollection`, `createMedalCollection`, `createRibbonCollection` — the catalog trio (formal award/medal/ribbon definitions), sharing a `catalogItemFields.ts` builder set (shared field/slug builders, not a rigid shared array — the three real collections' field orders and eligibility shapes genuinely diverge). `createAwardPresentationCollection` — the "who actually got one" record, wiring `createDisplayNameComposeHook` (compose seam), an opt-in `createAwardedBySelfEnforcementHook` (admin "on behalf of" bypass, non-admin forced to self), and a default-on `createRecipientCountSyncHook` (`payload.count()`, never `find({limit:0})` — the historical N+1 regression this cluster's own pins guard against). `createKudosCollection` — peer-to-peer recognition, `update` access via the new exported `createGiverWindowAccess` factory (originator self-edit within a configurable minute window). `createMemorialCollection` — public "In Memoriam" tributes, published-gated read access. `registerRecognitionGdpr`/`unregisterRecognitionGdpr` (`packages/org/src/collections/recognition/gdpr.ts`) — kudos hard-deletes the erased member's own rows as RECIPIENT while separately null-refing rows where they were the GIVER (one registry slot, two dispositions); award-presentations and memorials retain. Awards/Medals/Ribbons are deliberately never registered — catalog/definition rows carry no member relationship to key an erasure off of. Every factory threads the shared `fieldShape` mechanism (`fieldOverrides`/`fieldOrder`/`omitFields`/`fieldDescriptions`-null-suppress/`extraFields`/`extraFieldsAfter`) and per-verb `access` overrides — no held-out defaults. Discord fan-out and any web-framework cache-tag revalidation stay consumer-side (Wave 9 doctrine): no factory attaches notification hooks; `createRecipientCountSyncHook`'s `onExtraSync` is the composition seam for that coupling instead. **Opt-in only** — nothing in this cluster is wired into `createOrgLayer`; a consumer imports and assembles these six factories directly. `ORG_LAYER_VERSION` is unchanged.
v0.11.1patch

879a493: Wave 8 D-I1.1 — closes the seam gap the D-I2 adoption pass at a production consumer's integration branch stopped on: `createDocumentCollection` unconditionally emitted `freshnessDueDate`/`publishedAt` with no rename slot and no omission seam, so a consumer whose real Document has neither field could never reach a zero-type-diff shape. Adds a new shared `applyOmitFields(fields, omit)` helper to `fieldShape.ts` — the DELETE seam `applyFieldOverrides`/`applyFieldOrder`/`resolveFieldDescription` don't cover, since none of them can make a factory-emitted field disappear. Same error discipline as `applyFieldOrder` (an unknown name throws). Threaded through `createDocumentCollection` only, via a new `omitFields?: string[]` config option, applied AFTER `fieldOverrides` and BEFORE `fieldOrder` — an omitted field is already gone by the time `fieldOrder` runs, so a consumer's `fieldOrder` list never has to name a field it just deleted. `omitFields: ['freshnessDueDate', 'publishedAt']` removes exactly those two fields; omitting `omitFields` (the default) leaves both present, byte-identical to today. `applyOmitFields` is package-wide shared, but this increment threads it through only the Document factory — one gap, one factory. Every other factory in this package (`Event`, `EventAttendance`, `Division`, `Team`, `Rank`, ...) adopts the same helper on demand, if and when a real adopter hits an equivalent unconditional-field gap. Note in `applyOmitFields`'s own doc comment: omitting a field only removes it from the Payload schema/admin UI. If a consumer's existing data still populates that column, or site-level code still reads/writes it, this helper does not migrate or warn about that — reasoning about what happens to a still-populated field after omission is the consumer's responsibility, not this seam's. `ORG_LAYER_VERSION` untouched — additive seam, no default change.

  • 879a493: Wave 8 D-I1.1 — closes the seam gap the D-I2 adoption pass at a production consumer's integration branch stopped on: `createDocumentCollection` unconditionally emitted `freshnessDueDate`/`publishedAt` with no rename slot and no omission seam, so a consumer whose real Document has neither field could never reach a zero-type-diff shape. Adds a new shared `applyOmitFields(fields, omit)` helper to `fieldShape.ts` — the DELETE seam `applyFieldOverrides`/`applyFieldOrder`/`resolveFieldDescription` don't cover, since none of them can make a factory-emitted field disappear. Same error discipline as `applyFieldOrder` (an unknown name throws). Threaded through `createDocumentCollection` only, via a new `omitFields?: string[]` config option, applied AFTER `fieldOverrides` and BEFORE `fieldOrder` — an omitted field is already gone by the time `fieldOrder` runs, so a consumer's `fieldOrder` list never has to name a field it just deleted. `omitFields: ['freshnessDueDate', 'publishedAt']` removes exactly those two fields; omitting `omitFields` (the default) leaves both present, byte-identical to today. `applyOmitFields` is package-wide shared, but this increment threads it through only the Document factory — one gap, one factory. Every other factory in this package (`Event`, `EventAttendance`, `Division`, `Team`, `Rank`, ...) adopts the same helper on demand, if and when a real adopter hits an equivalent unconditional-field gap. Note in `applyOmitFields`'s own doc comment: omitting a field only removes it from the Payload schema/admin UI. If a consumer's existing data still populates that column, or site-level code still reads/writes it, this helper does not migrate or warn about that — reasoning about what happens to a still-populated field after omission is the consumer's responsibility, not this seam's. `ORG_LAYER_VERSION` untouched — additive seam, no default change.
v0.11.0minor

9e3a893: **Document factory threaded through the shared `fieldShape` mechanism (Wave 8 D-I1).** `createDocumentCollection` was the last org collection factory not wired to `applyFieldOverrides`/`applyFieldOrder`/`resolveFieldDescription` (`./fieldShape.ts`) — every other factory (`Event`, `EventAttendance`, `Division`, `Team`, `Rank`, ...) already carries it. Grown against a production consumer's integration branch's real Documents collection (`src/collections/Documents/index.ts`, 1158 LOC) so that collection can eventually become a thin adapter over this factory. A default `createDocumentCollection()` call is byte-identical to today — every seam below is opt-in. **New seams:** - `fieldNames` — renames `author`/`restrictedToDivision`/`restrictedToTeam`/`lastReviewedAt`/`scopedTo`/`status`. - `slugField: Field[] | false` — same `resolveSlugField` contract as `Event`/`Division`/`Team`/`Rank`. The single-field → `Field[]` replacement already generalizes a `slug` + `slugLock` companion-pair swap; no separate `replaceFields` seam was needed. - `scopeOptions`/`scopeDefault`/`scopeOrgWideValue` — `{mode,options}` override for the `scope` select, same contract as `Event.statusOptions`. - `scopedToMode: 'dual-fields' | 'polymorphic'` — `'dual-fields'` (default) keeps today's `scopedToDivision`/`scopedToTeam` pair. `'polymorphic'` emits ONE relationship field (`scopedToRelationTo`-injectable `relationTo`) — a production consumer's real `scopedTo` is a single polymorphic relation to `['wings', 'units', 'commissioned-ships']`. - `workflowStatusOptions`/`workflowStatusDefault` — `{mode,options}` override for `workflowStatus`. `includeStatusField` opt-in emits a SEPARATE `status` select (own vocabulary via `statusFieldOptions`/`statusFieldDefault`) for consumers who split publication state from review state. - `fieldOverrides`/`fieldOrder`/`fieldDescriptions` (suppress-via-`null` convention)/`extraFields`/`extraFieldsAfter` — the standard shared set. `fieldOverrides` also covers `documentId`'s unique-flag/`admin` divergences; no dedicated `documentIdField` seam was needed. `access` was already fully injectable per-verb (shallow-merged over the factory defaults) — verified a query-returning `Access` function round-trips through `config.access.read` unchanged. No new hooks: this factory stays hook-free by design. 38 new tests in `document-collection-w8-di1.test.ts` (587 total passing). `ORG_LAYER_VERSION` untouched.

  • 9e3a893: **Document factory threaded through the shared `fieldShape` mechanism (Wave 8 D-I1).** `createDocumentCollection` was the last org collection factory not wired to `applyFieldOverrides`/`applyFieldOrder`/`resolveFieldDescription` (`./fieldShape.ts`) — every other factory (`Event`, `EventAttendance`, `Division`, `Team`, `Rank`, ...) already carries it. Grown against a production consumer's integration branch's real Documents collection (`src/collections/Documents/index.ts`, 1158 LOC) so that collection can eventually become a thin adapter over this factory. A default `createDocumentCollection()` call is byte-identical to today — every seam below is opt-in. **New seams:** - `fieldNames` — renames `author`/`restrictedToDivision`/`restrictedToTeam`/`lastReviewedAt`/`scopedTo`/`status`. - `slugField: Field[] | false` — same `resolveSlugField` contract as `Event`/`Division`/`Team`/`Rank`. The single-field → `Field[]` replacement already generalizes a `slug` + `slugLock` companion-pair swap; no separate `replaceFields` seam was needed. - `scopeOptions`/`scopeDefault`/`scopeOrgWideValue` — `{mode,options}` override for the `scope` select, same contract as `Event.statusOptions`. - `scopedToMode: 'dual-fields' | 'polymorphic'` — `'dual-fields'` (default) keeps today's `scopedToDivision`/`scopedToTeam` pair. `'polymorphic'` emits ONE relationship field (`scopedToRelationTo`-injectable `relationTo`) — a production consumer's real `scopedTo` is a single polymorphic relation to `['wings', 'units', 'commissioned-ships']`. - `workflowStatusOptions`/`workflowStatusDefault` — `{mode,options}` override for `workflowStatus`. `includeStatusField` opt-in emits a SEPARATE `status` select (own vocabulary via `statusFieldOptions`/`statusFieldDefault`) for consumers who split publication state from review state. - `fieldOverrides`/`fieldOrder`/`fieldDescriptions` (suppress-via-`null` convention)/`extraFields`/`extraFieldsAfter` — the standard shared set. `fieldOverrides` also covers `documentId`'s unique-flag/`admin` divergences; no dedicated `documentIdField` seam was needed. `access` was already fully injectable per-verb (shallow-merged over the factory defaults) — verified a query-returning `Access` function round-trips through `config.access.read` unchanged. No new hooks: this factory stays hook-free by design. 38 new tests in `document-collection-w8-di1.test.ts` (587 total passing). `ORG_LAYER_VERSION` untouched.
v0.10.0minor

cb3f93e: Wave 7 I5.1/I6.1 — closes the seam gaps the I5 (`Events/index.ts`) and I6 (`EventAttendance`/`EventSlots`/`EventTypes`) adoption passes at a production consumer's integration branch hit and reported in their STOP lists. Every seam is opt-in with an identical default (`createEventCollection()`/`createEventAttendanceCollection()`/`createEventSlotCollection()`/`createEventTypeCollection()` with no config each emit their exact prior shape — the pre-existing default-shape baseline specs stay green, unmodified). **New shared mechanism (`packages/org/src/fieldShape.ts`), built once and threaded through all four events-cluster factories** — the design rule this increment works under: prefer ONE shared mechanism over ten bespoke options. - `applyFieldOverrides(fields, overrides)` — the `fieldNamed()` ELIMINATOR. Both adopt passes had to reach into a factory's already-built `fields` array by hand (I5's `Events/index.ts` restoring `required`/`index`/`label`/`admin.description`/field-level `access` on `organizer`/`coHosts`/`attendeeCount`/`waitlistCount`/`campaign`/`status`; I6's `EventSlots/index.ts` stripping `slotId`'s admin back off via a manual `.map()`). `fieldOverrides: Record<name, Partial<Field>>` merges `admin`/`access` one level deep, every other key overwrites, applied post-construction. - `applyFieldOrder(fields, order)` — post-construction reorder by field name; names listed come first in sequence, everything else keeps its original relative order and is appended after; an unknown name throws. This is the fix for I6's real blocking gap: `EventAttendance` couldn't be routed through the factory AT ALL because "the factory's fixed field-emission order cannot reproduce this collection's real order." - `resolveFieldDescription(defaultText, override)` — the three-state `fieldDescriptions: { x: null }` suppress convention (`undefined` keeps the default, a `string` overrides, `null` omits the `admin.description` key entirely). Closes `EventSlot.slotId`, the one field in the cluster the factory always described with no way to reach a production consumer's real "no admin key at all" shape. **`createEventCollection`:** `fieldOverrides`/`fieldOrder` (shared mechanism); `eventTypeOptions` (`{mode,options}`, same contract as `statusOptions`/`visibilityOptions` — `eventType` had no options seam at all before this); `slugField: Field[] | false` (same contract as `DivisionCollectionConfig.slugField`/`resolveSlugField.ts`, now shared by Event too — a production consumer's real `slugField()` helper is a `unique: false` slug + `slugLock` companion checkbox with a custom hook and admin component, a wholesale-replace case). **`createEventAttendanceCollection`:** `fieldOrder` (closes the real blocking gap above); `fieldOverrides`; `extraIndexes: { fields, unique? }[]` (a production consumer's real three beyond `[event, member]`: `[event, status]`, `[member, status]`, `[event, selectedSlotId]` unique). Converges the local `insertFieldsAfterLocal` TODO onto the now-merged shared `insertFieldsAfter`. **`createEventSlotCollection`:** `extraFields`/`extraFieldsAfter` (previously append-only, no anchor); `fieldOverrides`/`fieldOrder`; `slotId`/`slotIndex` now route through `resolveFieldDescription`, so `fieldDescriptions: { slotId: null }` suppresses the field's `admin` key entirely (a production consumer's real shape — the I6 adopt pass had to strip it post-construction). **`createSlotSyncHook`:** `hintText({ dupKeys })` overrides the duplicate-key hint on the FAILED log line (default: this factory's own existing text, unchanged — NOT a production consumer's real string, a pre-existing divergence this increment doesn't silently "fix"); `logPrefix` overrides the `[createSlotSyncHook]` prefix on all three log lines (that consumer's real hook logs under `[syncEventSlots]`); `extendSlotData(slot, doc)` merges extra keys into both the create and update payloads (that consumer's real `requiredCertification`). **`createEventTypeCollection`:** `extraFields`/`extraFieldsAfter` (there was NO anchor mechanism at all before this — closes a production consumer's real `description` textarea, which has no factory correlate); `colorFieldType: 'text' | 'select'` + `colorOptions` (that consumer's real `color` is a palette `select`, not freeform text — a field TYPE mismatch `fieldOverrides` can't reach); `fieldOverrides` (closes the remaining five real diffs: `label.unique`+description, `value.validate`, `shortLabel.required`, `order`'s default value, `isActive.admin`). **`createRestrictStatusChangeHook`/`createEnforceSelfOnlyHook`:** both gain `skipWhenNoUser`. `restrictStatusChange`'s default is `false` (today's exact behavior — always resolves the Event and calls `isPrivileged` regardless of `req.user`); `true` reproduces a production consumer's real `!req.user || checkRole(...)` early bypass, including skipping the Event lookup (`findByID`) entirely — a genuine gap the old `isPrivileged`-only seam couldn't close (a consumer's predicate could reject an anonymous request but not skip the DB call). `enforceSelfOnly`'s default is `true` — this hook already reproduces that consumer's `!req.user` bypass unconditionally (`walkInHookBypass.spec.ts`'s pinned source-text assertions match today's code exactly); the option NAMES that existing behavior rather than changing it, and `false` opts into enforcing the member-id check even for an unauthenticated create. New test files: `tests/field-shape.test.ts` (19 tests, the shared mechanism in isolation), `tests/event-collection-w7-i5-1.test.ts` (16 tests), `tests/event-attendance-collection-w7-i6-1.test.ts` (15 tests), `tests/event-slot-w7-i6-1.test.ts` (17 tests), `tests/event-type-w7-i6-1.test.ts` (12 tests) — 79 new tests, 549/549 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 148/148 PASS. `ORG_LAYER_VERSION` untouched — every seam here is export-only/opt-in, none auto-wired into `createOrgLayer`.

  • cb3f93e: Wave 7 I5.1/I6.1 — closes the seam gaps the I5 (`Events/index.ts`) and I6 (`EventAttendance`/`EventSlots`/`EventTypes`) adoption passes at a production consumer's integration branch hit and reported in their STOP lists. Every seam is opt-in with an identical default (`createEventCollection()`/`createEventAttendanceCollection()`/`createEventSlotCollection()`/`createEventTypeCollection()` with no config each emit their exact prior shape — the pre-existing default-shape baseline specs stay green, unmodified). **New shared mechanism (`packages/org/src/fieldShape.ts`), built once and threaded through all four events-cluster factories** — the design rule this increment works under: prefer ONE shared mechanism over ten bespoke options. - `applyFieldOverrides(fields, overrides)` — the `fieldNamed()` ELIMINATOR. Both adopt passes had to reach into a factory's already-built `fields` array by hand (I5's `Events/index.ts` restoring `required`/`index`/`label`/`admin.description`/field-level `access` on `organizer`/`coHosts`/`attendeeCount`/`waitlistCount`/`campaign`/`status`; I6's `EventSlots/index.ts` stripping `slotId`'s admin back off via a manual `.map()`). `fieldOverrides: Record<name, Partial<Field>>` merges `admin`/`access` one level deep, every other key overwrites, applied post-construction. - `applyFieldOrder(fields, order)` — post-construction reorder by field name; names listed come first in sequence, everything else keeps its original relative order and is appended after; an unknown name throws. This is the fix for I6's real blocking gap: `EventAttendance` couldn't be routed through the factory AT ALL because "the factory's fixed field-emission order cannot reproduce this collection's real order." - `resolveFieldDescription(defaultText, override)` — the three-state `fieldDescriptions: { x: null }` suppress convention (`undefined` keeps the default, a `string` overrides, `null` omits the `admin.description` key entirely). Closes `EventSlot.slotId`, the one field in the cluster the factory always described with no way to reach a production consumer's real "no admin key at all" shape. **`createEventCollection`:** `fieldOverrides`/`fieldOrder` (shared mechanism); `eventTypeOptions` (`{mode,options}`, same contract as `statusOptions`/`visibilityOptions` — `eventType` had no options seam at all before this); `slugField: Field[] | false` (same contract as `DivisionCollectionConfig.slugField`/`resolveSlugField.ts`, now shared by Event too — a production consumer's real `slugField()` helper is a `unique: false` slug + `slugLock` companion checkbox with a custom hook and admin component, a wholesale-replace case). **`createEventAttendanceCollection`:** `fieldOrder` (closes the real blocking gap above); `fieldOverrides`; `extraIndexes: { fields, unique? }[]` (a production consumer's real three beyond `[event, member]`: `[event, status]`, `[member, status]`, `[event, selectedSlotId]` unique). Converges the local `insertFieldsAfterLocal` TODO onto the now-merged shared `insertFieldsAfter`. **`createEventSlotCollection`:** `extraFields`/`extraFieldsAfter` (previously append-only, no anchor); `fieldOverrides`/`fieldOrder`; `slotId`/`slotIndex` now route through `resolveFieldDescription`, so `fieldDescriptions: { slotId: null }` suppresses the field's `admin` key entirely (a production consumer's real shape — the I6 adopt pass had to strip it post-construction). **`createSlotSyncHook`:** `hintText({ dupKeys })` overrides the duplicate-key hint on the FAILED log line (default: this factory's own existing text, unchanged — NOT a production consumer's real string, a pre-existing divergence this increment doesn't silently "fix"); `logPrefix` overrides the `[createSlotSyncHook]` prefix on all three log lines (that consumer's real hook logs under `[syncEventSlots]`); `extendSlotData(slot, doc)` merges extra keys into both the create and update payloads (that consumer's real `requiredCertification`). **`createEventTypeCollection`:** `extraFields`/`extraFieldsAfter` (there was NO anchor mechanism at all before this — closes a production consumer's real `description` textarea, which has no factory correlate); `colorFieldType: 'text' | 'select'` + `colorOptions` (that consumer's real `color` is a palette `select`, not freeform text — a field TYPE mismatch `fieldOverrides` can't reach); `fieldOverrides` (closes the remaining five real diffs: `label.unique`+description, `value.validate`, `shortLabel.required`, `order`'s default value, `isActive.admin`). **`createRestrictStatusChangeHook`/`createEnforceSelfOnlyHook`:** both gain `skipWhenNoUser`. `restrictStatusChange`'s default is `false` (today's exact behavior — always resolves the Event and calls `isPrivileged` regardless of `req.user`); `true` reproduces a production consumer's real `!req.user || checkRole(...)` early bypass, including skipping the Event lookup (`findByID`) entirely — a genuine gap the old `isPrivileged`-only seam couldn't close (a consumer's predicate could reject an anonymous request but not skip the DB call). `enforceSelfOnly`'s default is `true` — this hook already reproduces that consumer's `!req.user` bypass unconditionally (`walkInHookBypass.spec.ts`'s pinned source-text assertions match today's code exactly); the option NAMES that existing behavior rather than changing it, and `false` opts into enforcing the member-id check even for an unauthenticated create. New test files: `tests/field-shape.test.ts` (19 tests, the shared mechanism in isolation), `tests/event-collection-w7-i5-1.test.ts` (16 tests), `tests/event-attendance-collection-w7-i6-1.test.ts` (15 tests), `tests/event-slot-w7-i6-1.test.ts` (17 tests), `tests/event-type-w7-i6-1.test.ts` (12 tests) — 79 new tests, 549/549 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 148/148 PASS. `ORG_LAYER_VERSION` untouched — every seam here is export-only/opt-in, none auto-wired into `createOrgLayer`.
v0.9.0minor

54f8009: Wave 7 org-adoption I1 — `createEventCollection` grown seam-complete against the real Events collection read at a production consumer's integration branch (`Events/index.ts`, 1705 LOC + 7 relevant hook files). Every option is opt-in with an MVP-identical default (`createEventCollection()` with no config emits the exact 19-field shape it always has — see `event-collection-w7-i1.test.ts`'s baseline); no factory in `createOrgLayer` calls any of the new options. **Field-shape seams:** `statusOptions`/`statusDefault` (a production consumer carries the MVP's seven lifecycle values plus two legacy DB-compatibility values, `draft`/`published`, it has never migrated off of); `fieldNames` (renames `division`/`team`/`organizer`/`coHosts`/`securityLevel`/`attendeeCount`/the two new participating-\* fields — that consumer: `leadUnit`, `participatingWings`/`participatingUnits`, `visibility`); `includeParticipatingDivisions`/`includeParticipatingTeams` (two NEW opt-in hasMany scope fields the MVP never modeled); `visibilityOptions`/`visibilityDefault` (that consumer's `visibility` values, `public`/`members`/`restricted`, diverge from the MVP's `public`/`members-only`/`restricted`); `locationField` (wholesale override for the MVP's freeform text field — that consumer's real `location` is a structured group); `coHostsMaxRows` (that consumer caps at 5, MVP unlimited); `includeWaitlistCount` (a NEW opt-in readOnly counter beside `attendeeCount` — the MVP has no waitlist model); `fieldDescriptions` (every previously-hardcoded `admin.description` string is now overridable — 2R-I6 precedent: a factory's generic wording replacing a consumer's own string, with no way back, is a rejected-round finding). **`extraFields`/`extraFieldsAfter`:** interior-positioned passthrough via a NEW shared helper, `insertFieldsAfter(fields, anchor, extra)` (`packages/org/src/insertFieldsAfter.ts`) — no factory in this package had an anchor mechanism before this. A production consumer's real `leadUnit`/`participatingUnits`/`participatingWings` sit immediately after `coHosts`, well before the capacity/counter fields near the end of this factory's array; the tail-append convention every other factory's `extraFields` uses (`Rank`/`Promotion`/`StatusRequest`/`ExpertDesignation`) can't reproduce that. Omitting `extraFieldsAfter` keeps the append-at-end default. **Four adoptable hook factories, export-only, none auto-wired** (same posture as every other adoptable hook in this package): - `createClosedRecordLockHook` (`event-closed-record-lock.ts`) — ports `enforceClosedRecordLock`: value-diff (not key-presence) based, blocks writes to a terminal-status event outside an `allowlist`, bypassable via `bypassContextKeys` (default: a production consumer's own `bypassEventLock`/`migrationBackfill`). - `createLifecycleLogHook` (`event-lifecycle-log.ts`) — ports `logLifecycleTransition`: append-only log REBUILT from `originalDoc` only on every save (client-supplied log data always discarded, array-item ids stripped so the save replaces wholesale), actor attribution priority chain (context key → injectable/default member-lookup resolver → system, `auto: true`). - `createEventCascadeDeleteHooks` (`event-cascade-delete.ts`) — generalizes a production consumer's four near-identical `delete*.ts` afterDelete hooks (event-slots/resource-requests/event-attendance/after-action-reports) into one `{collection,field}[]`-driven factory, per-target error isolation, never throws. - `createNotificationResolverOnDeleteHook` (`event-notification-resolver.ts`) — ports `resolveEventNotificationsOnDelete`: same `resolveWrite`/`onResolved` injectable seam shape as Wave 6's `createPromotionNotificationCleanupHook`, adapted to that consumer's real fixed-suffix EXACT-dedupKey resolution (`events:{id}:exam-pending`/`aar-missing`) rather than promotion's unbounded prefix scan. Never throws. New test files `tests/event-collection-w7-i1.test.ts` (34 tests: byte-identical-defaults baseline + every field-shape seam) and `tests/event-hooks-w7-i1.test.ts` (29 tests: all four hook factories, including allowlist/bypass, originalDoc-rebuild, per-target isolation, never-throws). 384/384 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 132/132 PASS. `ORG_LAYER_VERSION` is NOT bumped (release-time task). **Not seamed this increment (named per the build brief, not approximated):** `getEffectiveStatus`'s time-based lock derivation (an event whose end time has passed behaves as completed even before the status-flip cron runs) is consumer-specific — it reads `dateTime.startDate`/`endDate` field names this factory does not own — so `createClosedRecordLockHook`'s `terminalStatuses` check is value-based against `statusField` only; a consumer wanting the time-based derivation keeps a synced "effective status" field and points `statusField` at it.

  • 54f8009: Wave 7 org-adoption I1 — `createEventCollection` grown seam-complete against the real Events collection read at a production consumer's integration branch (`Events/index.ts`, 1705 LOC + 7 relevant hook files). Every option is opt-in with an MVP-identical default (`createEventCollection()` with no config emits the exact 19-field shape it always has — see `event-collection-w7-i1.test.ts`'s baseline); no factory in `createOrgLayer` calls any of the new options. **Field-shape seams:** `statusOptions`/`statusDefault` (a production consumer carries the MVP's seven lifecycle values plus two legacy DB-compatibility values, `draft`/`published`, it has never migrated off of); `fieldNames` (renames `division`/`team`/`organizer`/`coHosts`/`securityLevel`/`attendeeCount`/the two new participating-\* fields — that consumer: `leadUnit`, `participatingWings`/`participatingUnits`, `visibility`); `includeParticipatingDivisions`/`includeParticipatingTeams` (two NEW opt-in hasMany scope fields the MVP never modeled); `visibilityOptions`/`visibilityDefault` (that consumer's `visibility` values, `public`/`members`/`restricted`, diverge from the MVP's `public`/`members-only`/`restricted`); `locationField` (wholesale override for the MVP's freeform text field — that consumer's real `location` is a structured group); `coHostsMaxRows` (that consumer caps at 5, MVP unlimited); `includeWaitlistCount` (a NEW opt-in readOnly counter beside `attendeeCount` — the MVP has no waitlist model); `fieldDescriptions` (every previously-hardcoded `admin.description` string is now overridable — 2R-I6 precedent: a factory's generic wording replacing a consumer's own string, with no way back, is a rejected-round finding). **`extraFields`/`extraFieldsAfter`:** interior-positioned passthrough via a NEW shared helper, `insertFieldsAfter(fields, anchor, extra)` (`packages/org/src/insertFieldsAfter.ts`) — no factory in this package had an anchor mechanism before this. A production consumer's real `leadUnit`/`participatingUnits`/`participatingWings` sit immediately after `coHosts`, well before the capacity/counter fields near the end of this factory's array; the tail-append convention every other factory's `extraFields` uses (`Rank`/`Promotion`/`StatusRequest`/`ExpertDesignation`) can't reproduce that. Omitting `extraFieldsAfter` keeps the append-at-end default. **Four adoptable hook factories, export-only, none auto-wired** (same posture as every other adoptable hook in this package): - `createClosedRecordLockHook` (`event-closed-record-lock.ts`) — ports `enforceClosedRecordLock`: value-diff (not key-presence) based, blocks writes to a terminal-status event outside an `allowlist`, bypassable via `bypassContextKeys` (default: a production consumer's own `bypassEventLock`/`migrationBackfill`). - `createLifecycleLogHook` (`event-lifecycle-log.ts`) — ports `logLifecycleTransition`: append-only log REBUILT from `originalDoc` only on every save (client-supplied log data always discarded, array-item ids stripped so the save replaces wholesale), actor attribution priority chain (context key → injectable/default member-lookup resolver → system, `auto: true`). - `createEventCascadeDeleteHooks` (`event-cascade-delete.ts`) — generalizes a production consumer's four near-identical `delete*.ts` afterDelete hooks (event-slots/resource-requests/event-attendance/after-action-reports) into one `{collection,field}[]`-driven factory, per-target error isolation, never throws. - `createNotificationResolverOnDeleteHook` (`event-notification-resolver.ts`) — ports `resolveEventNotificationsOnDelete`: same `resolveWrite`/`onResolved` injectable seam shape as Wave 6's `createPromotionNotificationCleanupHook`, adapted to that consumer's real fixed-suffix EXACT-dedupKey resolution (`events:{id}:exam-pending`/`aar-missing`) rather than promotion's unbounded prefix scan. Never throws. New test files `tests/event-collection-w7-i1.test.ts` (34 tests: byte-identical-defaults baseline + every field-shape seam) and `tests/event-hooks-w7-i1.test.ts` (29 tests: all four hook factories, including allowlist/bypass, originalDoc-rebuild, per-target isolation, never-throws). 384/384 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 132/132 PASS. `ORG_LAYER_VERSION` is NOT bumped (release-time task). **Not seamed this increment (named per the build brief, not approximated):** `getEffectiveStatus`'s time-based lock derivation (an event whose end time has passed behaves as completed even before the status-flip cron runs) is consumer-specific — it reads `dateTime.startDate`/`endDate` field names this factory does not own — so `createClosedRecordLockHook`'s `terminalStatuses` check is value-based against `statusField` only; a consumer wanting the time-based derivation keeps a synced "effective status" field and points `statusField` at it.
  • 63f50b5: Grow `createEventAttendanceCollection` seam-complete for adoption by a production consumer (Wave 7 I2), ported from that consumer's real `EventAttendance` collection (its integration branch, `src/collections/EventAttendance/index.ts` + its `hooks/` directory). All additive and opt-in — the zero-arg default output is unchanged (same field set, same single `beforeValidate` uniqueness hook, no `indexes` key). - `statusOptions` extend/replace override — the MVP's own nine-value vocabulary already matches that consumer's real nine by value (kept verbatim as the default). - `uniquenessStrategy: 'index' | 'hook'` — `'hook'` (DEFAULT, unchanged) is today's find-then-throw `createAttendanceUniqueHook`, TOCTOU-racy under concurrent writes; `'index'` adds a real unique compound Mongo index on `[event, member]` instead, no hook attached. - Ten opt-in field groups, each gated behind its own `includeX` flag and name-overridable via `fieldNames`/`committedAssetFieldNames`/`readinessFieldNames`: `selectedSlotId`, `readiness` (5-subfield advisory group), `trainingOutcome` (passed/failed/incomplete — deliberately **never** gets an `admin.condition`; the doc comment records a production consumer's production history of a stale relationship-ID condition hiding the field for the collection's entire life), `auditGrades`+`calibrationDeviation`, `committedAsset` (fleet-asset pledge group; the ship-catalog lookup hook is NOT ported), `operationalStatus`, `rsvpDate` (auto-stamped on create), `attendanceConfirmedBy`/`attendanceConfirmedDate` (gated on `attendedStatusValue`), and `adminNotes` (field-level `access` seam, default gated on `MANAGE_ATTENDANCE`/`MANAGE_EVENTS`). - `fieldDescriptions` override for every hardcoded `admin.description` this factory can emit (same contract as Wave 7 I1's `EventFieldDescriptions`). - `computeDisplayName`/`displayNameCompose` — default OFF (the MVP's `displayName` field exists today but nothing populates it; defaulting this on would add a hook where none exists, breaking the default-shape guarantee). Same naming as `ConductRecordCollectionConfig`'s W6 seam. - `extraFields`/`extraFieldsAfter` — local `insertFieldsAfterLocal` helper (the shared `insertFieldsAfter` module lives only on the unmerged Wave 7 I1 branch as of this writing; TODO comment marks the post-merge converge point). - Four standalone hook factories, export-only and opt-in via dedicated config keys (`restrictStatusChange`, `enforceSelfOnly`, `attendeeCountSync`, `engagementOnFirstAttendance`) that thread this factory's own resolved slugs automatically: - `createRestrictStatusChangeHook` — port of `restrictStatusChange`; injectable `isPrivileged(req, event)` (a production consumer: organizer-OR-co-host). - `createEnforceSelfOnlyHook` — port of `enforceSelfOnly`; injectable `resolveMemberId`. - `createAttendeeCountSyncHook` — port of `syncAttendeeCount`; uses `payload.count()`, never `find({ limit: 0 })` (that consumer's own documented anti-pattern fix); `onExtraSync` seam for ceremony/promotions maintenance. - `createEngagementOnFirstAttendanceHook` — port of `updateOnboarding`'s idempotent first-attendance flip; `isEngaged` idempotency gate + consumer-owned `write`.
  • 3240131: Wave 7 I3 — upstreams a production consumer's EventSlot mirror as two new opt-in exports: `createEventSlotCollection` (default slug `org-event-slots`) and `createSlotSyncHook`. Neither is wired into `createOrgLayer` (design principle 2, same posture as DelegatedAuthority/PersonnelNotes) — a consumer attaches both explicitly. **`createEventSlotCollection`** ports a production consumer's integration branch's `EventSlots/index.ts` (98 LOC): `event` relationship (slug injectable via `eventSlug`), `slotId`, and the denormalized descriptor fields (`groupIndex`/`assetIndex`/`slotIndex`/`roleName`/`isCommander`) — every field name overridable via `fieldNames`, every admin description overridable via `fieldDescriptions`. LMS-owned relations (`requiredCertification`, `requiredSpecialistPath`) are deliberately NOT modeled — same cross-wave-edges rule already applied in `ExpertDesignation.ts`/`DelegatedAuthority.ts` — add them via `extraFields`. `read` defaults to `authenticated`; `create`/`update`/`delete` default-gate on `CREATE_EVENTS`/`MANAGE_EVENTS`, fully overridable via `access`. **CRITICAL, pinned by test:** the `[event, slotId]` compound index is emitted WITHOUT `unique: true`, by design — a real DB-level unique index here previously caused `syncEventSlots`'s `create()` to throw a silently-swallowed MongoDB E11000 on any slotId reuse (the CMS "Duplicate" action being the reproducing case), leaving an event with zero mirror rows and every slot unclaimable with no visible symptom. Real de-duplication lives ONLY in the sync hook's reconcile pass, never the database. `tests/event-slot.test.ts` asserts both the field-level and the compound-index level carry no unique flag — do not "fix" this into a unique constraint. **`createSlotSyncHook`** ports `Events/hooks/syncEventSlots.ts` (225 LOC) as an Event-collection `afterChange` hook factory: an injectable `extractSlots(doc)` extracts the flat slot list from whatever nested shape a consumer's Event schema uses (a production consumer: `operationDetails.operationalGroups[].assets[].crewSlots[]` — this factory has no knowledge of that shape). Reconciles by deleting rows that fell out of the current slot set and creating-or-updating rows that are still current, with every current-slot write isolated under `Promise.allSettled` (one failing slot — classically a duplicate-key race — never aborts the batch or the others). Clears orphaned attendance `selectedSlotId`-equivalent references via an injectable `attendance` config (slug + field names), opt-out via `attendance: false` or omitting it. NEVER throws: the whole body runs under a top-level try/catch routed through the package's existing `reportOrgSyncFailure`/`onSyncFailure` seam (same convention as `member-count-sync.ts`) — an Event save must succeed even when slot sync fails outright. New test file `tests/event-slot.test.ts` (20 tests) — factory shape, the non-unique-index assertions, `fieldNames`/`fieldDescriptions`/`extraFields`/`access` overrides, sync-hook create/delete/update paths, `Promise.allSettled` isolation with the E11000 hint text, the never-throws guarantee (including a custom `onSyncFailure` reporter), and the attendance-clearing opt-out. 341/341 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 126/126 PASS. Isolation note: PRs #341 (`events/Event.ts`, `hooks/event-*.ts`, `insertFieldsAfter.ts`) and #342 (`events/EventAttendance.ts`, `types.ts` additions) are in flight concurrently. This PR touches none of their files — `EventSlot.ts` and `event-slot-sync.ts` are new files, and the only edits to shared barrels (`collections/events/index.ts`, `hooks/index.ts`, `src/index.ts`) are append-only new export lines. `types.ts` is untouched entirely: `EventSlotCollectionConfig`/`SlotSyncHookConfig` are defined locally in their own files rather than added to the shared config-type file `#342` is already extending, and the collection's own default slug (`DEFAULT_EVENT_SLOT_SLUG = 'org-event-slots'`) is a local constant rather than a new `ORG_DEFAULT_SLUGS.eventSlots` entry — a follow-up can fold it into `OrgSlugs` once the sibling PRs land. `createOrgLayer` and `ORG_LAYER_VERSION` are untouched; wiring slots into the layer factory is a deferred, explicit follow-up decision, not assumed here.
  • 6b242a4: Wave 7 I4 — absorbs the EventType delete guard `EventType.ts`'s own header deferred to MVP+1, behind a new opt-in `includeDeleteGuard` (default `false`, byte-identical to today's factory). **`createEventTypeCollection({ includeDeleteGuard: true })`** adds a `beforeDelete` hook (`createEventTypeDeleteGuard`, new export) that blocks deletion while any Event still references the type, throwing a `400 APIError`. Ported from a production consumer's integration branch's `src/collections/EventTypes/index.ts` beforeDelete hook. Two `matchOn` modes seam the field-shape divergence between today's runtime and upstream's own documented future direction: `'value'` (default) counts Events where `eventTypeField` (default `'eventType'`) equals the EventType doc's `value` STRING — that consumer's actual semantics, since its `Events.eventType` is a `select` field storing that string, and this package's own `createEventCollection` ships the identical `select` shape today; `'relationship'` counts Events where `eventTypeField` equals the EventType doc's own `id`, for when `eventType` becomes a real relationship (that consumer's own file header names this as a future migration). `eventsSlug` defaults to `ORG_DEFAULT_SLUGS.events`. `deleteBlockedMessage` overrides the thrown text; the built-in default reproduces that consumer's exact string (`Cannot delete "${label}": ${count} event(s) are using this type. Re-assign them first.`). **`fieldDescriptions`** makes the three previously-hardcoded `admin.description` strings (`value`, `color`, `defaultFeatures`) overridable — same contract as Wave 7 I1/I2. New test file `tests/event-type-guard.test.ts` (17 tests) — default-shape byte-identical gate, `fieldDescriptions` overrides, guard wiring (hook count, mergeHooks append with a consumer's own hooks), both `matchOn` modes, `deleteBlockedMessage` override, custom `eventsSlug`/`eventTypeField`, and the zero-usage / no-value pass-through paths. Isolation note: PRs #341 (`events/Event.ts`, `hooks/event-*.ts`, `insertFieldsAfter.ts`), #342 (`events/EventAttendance.ts`, `types.ts` additions), and #343 (`events/EventSlot.ts`, `hooks/event-slot-sync.ts`, barrels) are in flight concurrently. This PR touches none of their files: `event-type-delete-guard.ts` and `event-type-guard.test.ts` are new files, and the only edits to shared files (`collections/events/EventType.ts`, `hooks/index.ts`, `src/index.ts`) are either fully self-contained (EventType.ts, not touched by any sibling) or append-only new export lines (the two barrels). `types.ts` is untouched entirely: `EventTypeFactoryConfig`/`EventTypeFieldDescriptions` are defined locally in `EventType.ts` (extending the existing, unmodified `EventTypeCollectionConfig` import) rather than added to `types.ts`, which #342 is already extending. `createOrgLayer` and `ORG_LAYER_VERSION` are untouched — this guard is export-only/opt-in, never auto-wired into the layer factory.
v0.8.0minor

5e85ff2: Wave 6 org-adoption I8 — the last three org-domain personnel factories: `createStatusRequestCollection`, `createConductRecordCollection`, `createExpertDesignationCollection`. All opt-in, none wired into `createOrgLayer` (design principle 2). Ownership split: org owns the RECORD shape; a transition engine (e.g. `@wabbit/tome-workflow`) owns any TRANSITION logic — none of the three factories model a transition/execution hook. **`createStatusRequestCollection`** (default slug `status-requests`, a production consumer: `member-status-requests`, 14 live rows). 18 neutral fields absorbed from `MemberStatusRequests/index.ts` verbatim. `type`/`status` default to that consumer's own five-value vocabularies (generic HR/pipeline terms); `approverScope` ships a Tome-neutral default (`division_lead`/`organization`) since that consumer's real values (`wing_co`/`vlc`) are organization-specific jargon — replace wholesale via `approverScopeOptions`. The grouped `approval`/`denial` fields reuse Promotion's W6-I6 shape verbatim via a new shared helper, `buildApprovalDenialGroups` (`collections/personnel/shared/approvalDenialGroups.ts`) — extracted so this factory doesn't fork the same field arrays a second time; `createPromotionCollection`'s own inline fields are left untouched (byte-identical `factory-snapshots.test.ts` guarantee, no regression risk taken). The built-in `title`-composition hook (`computeTitle`, default `true`) reproduces that consumer's inline `beforeChange` exactly, reading a configurable `titleDisplayField` (default `'displayName'`; that consumer: `'rsiHandle'`). **NOT modeled, by design:** the donor consumer's `executeStatusRequest` afterChange hook. It writes the settled status back via a DIRECT MONGO `updateOne`, bypassing Payload hooks entirely — **that bypass IS the infinite-loop guard**. Routing it through `payload.update()` would re-fire the same afterChange hook and offboard the member TWICE. Per the wave plan's Wave 4 precedent, a consumer wires this as a `workflow.ts` module beside their adapter. **`createConductRecordCollection`** (default slug `conduct-records`, a production consumer: `discipline-records`, 2 live rows). 20 fields — every field name defaults to that consumer's own verbatim (only the collection slug differs by default; no field-shape divergence was found). Absorbs `enforceCoolingPeriod` (`coolingPeriod` — new `createConductRecordCoolingPeriodHook`, `hooks/conduct-record-cooling-period.ts`), `setDisplayName` (`computeDisplayName`/`displayNameCompose`, default composes from `severityOptions`'s resolved label + a configurable `subjectDisplayField`), `autoAssignAppealReviewer`'s transition-detection bookkeeping (`autoAssignReviewer` — new `createConductRecordAutoAssignReviewerHook`, `hooks/conduct-record-appeal-reviewer.ts` — the chain-of-command WALK is an injected `poolQuery`), and `anonymizeForSubject` (`subjectRedaction` — new `createConductRecordSubjectRedactionHook`, `hooks/conduct-record-subject-redaction.ts` — the "is this reader the subject" ABAC is an injected `predicate`). `relatedStandingChange` gets a field-level `MODIFY_STANDING`-gated `access.update` default (`relatedStandingChangeAccess` to override). **Stays consumer-side, never modeled:** `triggerStandingChange` (cascades a standing change onto `members` — real re-entrant hook machinery this package doesn't own), `auditDisciplineRecord` (writes to an `audit-logs` collection this package doesn't own), and the 4 bespoke ABAC access functions (rank-floor + led-unit queries this package has no hierarchy resolver for). **`createExpertDesignationCollection`** (default slug `expert-designations`, a production consumer: `sme-designations`, 0 live rows). 10 fields — LMS territory (that consumer's `specialistPath`/`qualifyingTier`) is deliberately NOT modeled per the wave plan's cross-wave-edges rule; add it via `extraFields`. `canEndorse` is an endorsement-access seam layered on top of `MANAGE_SME_DESIGNATIONS`. Discord notification stays entirely consumer-side and runs last automatically — this factory attaches no `afterChange` hooks of its own, so a consumer's `notifyDiscord` passed via `hooks.afterChange` is appended after everything else by `mergeHooks` (design principle 5), reproducing that consumer's "Discord fires last" ordering with no special wiring. **GDPR:** `registerPersonnelGdpr` extended to register all three new collections as `retain`/post-identity (Wave 5 compliance plan §3a's own dispositions, verbatim), and closes two gaps the W6-I7 adopt pass hit: every one of the now-five registrations has independently-overridable `phase`/`order` (`*Phase`/`*Order` config keys) and its own `register*: boolean` opt-out (default `true`, mirrors `createFulfillmentLayer`'s/`createSCLayer`'s `registerGdpr: false`, scoped per-collection); `personnelNotesDeleteShape: 'bulk' | 'tolerant-per-row'` — `'tolerant-per-row'` finds then per-row try/catch deletes, matching a production consumer's real pinned erasure-engine shape for the 20,351-row `personnel-notes` collection (one bad row under `'bulk'` previously aborted the WHOLE delete with zero rows erased). New test files `tests/status-request-collection.test.ts` (20 tests), `tests/conduct-record-collection.test.ts` (29 tests), `tests/expert-designation-collection.test.ts` (17 tests); `tests/personnel-gdpr.test.ts` extended (10 → 22 tests). 321/321 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 122/122 PASS.

  • 5e85ff2: Wave 6 org-adoption I8 — the last three org-domain personnel factories: `createStatusRequestCollection`, `createConductRecordCollection`, `createExpertDesignationCollection`. All opt-in, none wired into `createOrgLayer` (design principle 2). Ownership split: org owns the RECORD shape; a transition engine (e.g. `@wabbit/tome-workflow`) owns any TRANSITION logic — none of the three factories model a transition/execution hook. **`createStatusRequestCollection`** (default slug `status-requests`, a production consumer: `member-status-requests`, 14 live rows). 18 neutral fields absorbed from `MemberStatusRequests/index.ts` verbatim. `type`/`status` default to that consumer's own five-value vocabularies (generic HR/pipeline terms); `approverScope` ships a Tome-neutral default (`division_lead`/`organization`) since that consumer's real values (`wing_co`/`vlc`) are organization-specific jargon — replace wholesale via `approverScopeOptions`. The grouped `approval`/`denial` fields reuse Promotion's W6-I6 shape verbatim via a new shared helper, `buildApprovalDenialGroups` (`collections/personnel/shared/approvalDenialGroups.ts`) — extracted so this factory doesn't fork the same field arrays a second time; `createPromotionCollection`'s own inline fields are left untouched (byte-identical `factory-snapshots.test.ts` guarantee, no regression risk taken). The built-in `title`-composition hook (`computeTitle`, default `true`) reproduces that consumer's inline `beforeChange` exactly, reading a configurable `titleDisplayField` (default `'displayName'`; that consumer: `'rsiHandle'`). **NOT modeled, by design:** the donor consumer's `executeStatusRequest` afterChange hook. It writes the settled status back via a DIRECT MONGO `updateOne`, bypassing Payload hooks entirely — **that bypass IS the infinite-loop guard**. Routing it through `payload.update()` would re-fire the same afterChange hook and offboard the member TWICE. Per the wave plan's Wave 4 precedent, a consumer wires this as a `workflow.ts` module beside their adapter. **`createConductRecordCollection`** (default slug `conduct-records`, a production consumer: `discipline-records`, 2 live rows). 20 fields — every field name defaults to that consumer's own verbatim (only the collection slug differs by default; no field-shape divergence was found). Absorbs `enforceCoolingPeriod` (`coolingPeriod` — new `createConductRecordCoolingPeriodHook`, `hooks/conduct-record-cooling-period.ts`), `setDisplayName` (`computeDisplayName`/`displayNameCompose`, default composes from `severityOptions`'s resolved label + a configurable `subjectDisplayField`), `autoAssignAppealReviewer`'s transition-detection bookkeeping (`autoAssignReviewer` — new `createConductRecordAutoAssignReviewerHook`, `hooks/conduct-record-appeal-reviewer.ts` — the chain-of-command WALK is an injected `poolQuery`), and `anonymizeForSubject` (`subjectRedaction` — new `createConductRecordSubjectRedactionHook`, `hooks/conduct-record-subject-redaction.ts` — the "is this reader the subject" ABAC is an injected `predicate`). `relatedStandingChange` gets a field-level `MODIFY_STANDING`-gated `access.update` default (`relatedStandingChangeAccess` to override). **Stays consumer-side, never modeled:** `triggerStandingChange` (cascades a standing change onto `members` — real re-entrant hook machinery this package doesn't own), `auditDisciplineRecord` (writes to an `audit-logs` collection this package doesn't own), and the 4 bespoke ABAC access functions (rank-floor + led-unit queries this package has no hierarchy resolver for). **`createExpertDesignationCollection`** (default slug `expert-designations`, a production consumer: `sme-designations`, 0 live rows). 10 fields — LMS territory (that consumer's `specialistPath`/`qualifyingTier`) is deliberately NOT modeled per the wave plan's cross-wave-edges rule; add it via `extraFields`. `canEndorse` is an endorsement-access seam layered on top of `MANAGE_SME_DESIGNATIONS`. Discord notification stays entirely consumer-side and runs last automatically — this factory attaches no `afterChange` hooks of its own, so a consumer's `notifyDiscord` passed via `hooks.afterChange` is appended after everything else by `mergeHooks` (design principle 5), reproducing that consumer's "Discord fires last" ordering with no special wiring. **GDPR:** `registerPersonnelGdpr` extended to register all three new collections as `retain`/post-identity (Wave 5 compliance plan §3a's own dispositions, verbatim), and closes two gaps the W6-I7 adopt pass hit: every one of the now-five registrations has independently-overridable `phase`/`order` (`*Phase`/`*Order` config keys) and its own `register*: boolean` opt-out (default `true`, mirrors `createFulfillmentLayer`'s/`createSCLayer`'s `registerGdpr: false`, scoped per-collection); `personnelNotesDeleteShape: 'bulk' | 'tolerant-per-row'` — `'tolerant-per-row'` finds then per-row try/catch deletes, matching a production consumer's real pinned erasure-engine shape for the 20,351-row `personnel-notes` collection (one bad row under `'bulk'` previously aborted the WHOLE delete with zero rows erased). New test files `tests/status-request-collection.test.ts` (20 tests), `tests/conduct-record-collection.test.ts` (29 tests), `tests/expert-designation-collection.test.ts` (17 tests); `tests/personnel-gdpr.test.ts` extended (10 → 22 tests). 321/321 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 122/122 PASS.
v0.7.0minor

64c22fc: Wave 6 org-adoption I3.1 + I7 — closes the four Position factory gaps the real adoption by a production consumer's billets hit (`69af1fe6`), and adds two new opt-in personnel factories under a consistent naming convention. All opt-in, defaults byte-identical. **I3.1 — Position follow-up.** `Billets/index.ts` (a production consumer's actual adapter over `createPositionCollection`) needed four things the I3 pass didn't model: - **`divisionField`/`teamField`:** Position's own `division`/`team` field names were hardcoded — that consumer's Billets call these `wing`/`unit`. Renaming also renames `admin.defaultColumns` and the field descriptions. - **`extraScopeFields`:** that consumer has a THIRD, mutually-exclusive scope anchor with no Tome structural correlate — `ship` (`relationTo: 'commissioned-ships'`). `scopeFields` (for `enforceScopeExclusivity`) now defaults to `[divisionField, teamField, ...extraScopeFields.map(f => f.name)]` automatically. - **`includeAuthorityTier`** (default `true`, today's shape): that consumer passes `false` — no use for a second, purely-descriptive classification column alongside its real `authorityLevel`. - **`categoryRequired`** (default `false`) **+ `categoryOptions`:** `category` had no requiredness/vocabulary override, unlike Rank's identically-shaped field. The consumer's `Billets.category` is `required: true`. New export `DEFAULT_POSITION_CATEGORY_OPTIONS`. - **`createPositionDeleteGuard`'s S2 check widened**, in `position-delete-guard.ts`: Team's leadership check goes from 2 fields (`leader`/`other`) to that consumer's real 4 (`leader`/`executiveOfficer`/`seniorNCO`/`other`) — the new two are only queried when explicitly named via `teamLeadershipFields`, so an unconfigured consumer's query stays byte-identical. - **`createPositionDerivationsHook`'s `composeDisplayTitle` signature changed** from `(data, ctx)` to a plain `{ title, scopeName, scopeKind }` object (nothing outside this package's own tests referenced the old shape) so a consumer's composer can react to ANY scope anchor — including `extraScopeFields` — not just division/team. The walk itself is generalized to N `scopeAnchors`, checked in priority order, wrapped in a single try/catch matching that consumer's own whole-chain (not per-branch) shape. New test file `tests/position-collection-w6-i3-1.test.ts` (20 tests, incl. a full consumer-shaped integration test: wing/unit/ship scopes, `includeAuthorityTier: false`, the 4-field Team guard, and a ship-anchored display title). `factory-snapshots.test.ts` and `position-collection-w6-i3.test.ts` untouched in behavior (two tests updated only for the `composeDisplayTitle` signature change) and green — every default output is unchanged. **I7 — two new factories, under a consistent naming convention decided the same day.** - **`createDelegatedAuthorityCollection`** (default slug `delegated-authority`, a production consumer: `acting-authority`, 0 live rows): temporary cross-scope command delegation, 11 fields mirroring that consumer's `ActingAuthority` verbatim under neutral names, all overridable via `fieldNames`. The `scope` select's option VALUES are derived from `divisionField`/`teamField` rather than a separate vocabulary — that consumer's real values (`wing`/`unit`) already equal its own field names for exactly this reason. The compound-uniqueness `beforeValidate` guard (at most one ACTIVE record per member/scope-entity/position) is always attached — a brand-new factory has no existing consumer to keep byte-identical for. `createDelegatedAuthorityExpiryTask` (new `hooks/delegated-authority-expiry.ts`) wraps the pure `expireDelegatedAuthority(payload, now, options)` as a standard `JobHandler` for `asPayloadTask`/`asCronEndpoint` (`@wabbit/tome-core/jobs`) — paged 50-at-a-time, optimistic re-check before each write, never deletes, never throws. - **`createPersonnelNotesCollection`** (default slug `personnel-notes`, a production consumer: `member-notes`, 20,351 live rows): 7 fields mirroring that consumer's `MemberNotes` verbatim. IMPORTANT: `author` is a MEMBER relationship, not `users` — matches that consumer's real shape (`fetchMember(req)`-resolved). `immutable` defaults `true` (`update: () => false`). Opt-in `authorRankOrder` config absorbs `setAuthorRankOrder` (denormalizes the author's rank order at create time, fail-secures to `99999` on any resolution failure, falls back to a direct Rank lookup when the author's `rank` relationship is an unpopulated ID). `auditCreate`/`auditRead` seams absorb `auditNoteCreation`/`logReadAccess` — both fire-and-log-only, never block the write/read on failure. - **`registerPersonnelGdpr`** (new `src/gdpr.ts`, mirrors `@wabbit/tome-sc`'s `registerScGdpr`): registers `delegated-authority` as `retain`/post-identity (org history) and `personnel-notes` as `hard-delete`/pre-identity with a CUSTOM `onDelete` — the registry's built-in `userField`/`memberField` OR pair can't express "match one `memberId` against two DIFFERENT member-keyed fields" (`member` AND `author`), which is what a production consumer's real "both directions" erasure actually needs. `deleteAs: ['member', 'author']` (default: both) narrows to one direction if ever needed. Dispositions match the Wave 5 compliance plan §3a's own rows for these two collections verbatim. New test files `tests/delegated-authority.test.ts` (18 tests), `tests/personnel-notes.test.ts` (19 tests), `tests/personnel-gdpr.test.ts` (10 tests). Neither factory is wired into `createOrgLayer` (design principle 2 — individual factories, explicit slugs, same as Position/Rank/Promotion). **I6.1 — Promotion follow-up.** `Promotions/index.ts` (a production consumer's actual adapter over `createPromotionCollection`, `26c86583` on that consumer's integration branch) hit three more gaps, documented in that commit's adapter header and pinned by `promotionsHooksParity.spec.ts`: - **`extraFields`:** the only one of the six adoptable factories in this wave without this passthrough — that consumer's 7 no-factory-correlate fields (`authorizedBy`, `systemGenerated`, `generatedBy`, `triggeredByAcademy`, `attendingCeremonies`, `calledAtCeremony`, `calledAt`) had nowhere to go until now. - **`approvalModel: 'grouped'` completeness**, all additive-only under new `validateBypassField`/`groupedModelOverrides` options: `validateBypassField` adds the array-level `validate` bypassing "at least one recommendation required" when the named field (that consumer: `systemGenerated`) is `true`; `groupedModelOverrides.{recommendations,approval,denial}` add `admin.condition` per group/array (that consumer hides `approval`/`denial` until populated or `status` matches), a `beforeValidate` hook seam on `recommendations[].reason` (that consumer: link canonicalization + inline-data-URI guard), and a rich-text `editor` override for `denial.reason` (that consumer needs a richer node vocabulary to avoid a Lexical crash on member-facing content). - **`createPromotionNotificationCleanupHook`'s resolve-write**: `promotionsHooksParity.spec.ts` proved the hook's one-field write (`{ bucket: 'done' }`) was a strict SUBSET of that consumer's real `resolveNotificationByDedupKey`, which writes FOUR fields (`bucket`/`resolutionMode`/`resolvedAt`/`expiresAt`) — missing `expiresAt` specifically meant the 30-day retention cron would never sweep the row. New `resolveWrite` (default: today's one-field write) and `onResolved` (default: none; that consumer: socket emit + triage-cache bust, called per resolved row, errors caught and reported per-row without aborting the sweep) close the gap to parity. New test file `tests/wave6-i6-1.test.ts` (20 tests). `factory-snapshots.test.ts` and `wave6-i2-1-i6.test.ts` untouched in behavior and green — every default output (including the pinned `approvalModel: 'grouped'` field shapes) is unchanged. 243/243 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 108/108 PASS.

  • 64c22fc: Wave 6 org-adoption I3.1 + I7 — closes the four Position factory gaps the real adoption by a production consumer's billets hit (`69af1fe6`), and adds two new opt-in personnel factories under a consistent naming convention. All opt-in, defaults byte-identical. **I3.1 — Position follow-up.** `Billets/index.ts` (a production consumer's actual adapter over `createPositionCollection`) needed four things the I3 pass didn't model: - **`divisionField`/`teamField`:** Position's own `division`/`team` field names were hardcoded — that consumer's Billets call these `wing`/`unit`. Renaming also renames `admin.defaultColumns` and the field descriptions. - **`extraScopeFields`:** that consumer has a THIRD, mutually-exclusive scope anchor with no Tome structural correlate — `ship` (`relationTo: 'commissioned-ships'`). `scopeFields` (for `enforceScopeExclusivity`) now defaults to `[divisionField, teamField, ...extraScopeFields.map(f => f.name)]` automatically. - **`includeAuthorityTier`** (default `true`, today's shape): that consumer passes `false` — no use for a second, purely-descriptive classification column alongside its real `authorityLevel`. - **`categoryRequired`** (default `false`) **+ `categoryOptions`:** `category` had no requiredness/vocabulary override, unlike Rank's identically-shaped field. The consumer's `Billets.category` is `required: true`. New export `DEFAULT_POSITION_CATEGORY_OPTIONS`. - **`createPositionDeleteGuard`'s S2 check widened**, in `position-delete-guard.ts`: Team's leadership check goes from 2 fields (`leader`/`other`) to that consumer's real 4 (`leader`/`executiveOfficer`/`seniorNCO`/`other`) — the new two are only queried when explicitly named via `teamLeadershipFields`, so an unconfigured consumer's query stays byte-identical. - **`createPositionDerivationsHook`'s `composeDisplayTitle` signature changed** from `(data, ctx)` to a plain `{ title, scopeName, scopeKind }` object (nothing outside this package's own tests referenced the old shape) so a consumer's composer can react to ANY scope anchor — including `extraScopeFields` — not just division/team. The walk itself is generalized to N `scopeAnchors`, checked in priority order, wrapped in a single try/catch matching that consumer's own whole-chain (not per-branch) shape. New test file `tests/position-collection-w6-i3-1.test.ts` (20 tests, incl. a full consumer-shaped integration test: wing/unit/ship scopes, `includeAuthorityTier: false`, the 4-field Team guard, and a ship-anchored display title). `factory-snapshots.test.ts` and `position-collection-w6-i3.test.ts` untouched in behavior (two tests updated only for the `composeDisplayTitle` signature change) and green — every default output is unchanged. **I7 — two new factories, under a consistent naming convention decided the same day.** - **`createDelegatedAuthorityCollection`** (default slug `delegated-authority`, a production consumer: `acting-authority`, 0 live rows): temporary cross-scope command delegation, 11 fields mirroring that consumer's `ActingAuthority` verbatim under neutral names, all overridable via `fieldNames`. The `scope` select's option VALUES are derived from `divisionField`/`teamField` rather than a separate vocabulary — that consumer's real values (`wing`/`unit`) already equal its own field names for exactly this reason. The compound-uniqueness `beforeValidate` guard (at most one ACTIVE record per member/scope-entity/position) is always attached — a brand-new factory has no existing consumer to keep byte-identical for. `createDelegatedAuthorityExpiryTask` (new `hooks/delegated-authority-expiry.ts`) wraps the pure `expireDelegatedAuthority(payload, now, options)` as a standard `JobHandler` for `asPayloadTask`/`asCronEndpoint` (`@wabbit/tome-core/jobs`) — paged 50-at-a-time, optimistic re-check before each write, never deletes, never throws. - **`createPersonnelNotesCollection`** (default slug `personnel-notes`, a production consumer: `member-notes`, 20,351 live rows): 7 fields mirroring that consumer's `MemberNotes` verbatim. IMPORTANT: `author` is a MEMBER relationship, not `users` — matches that consumer's real shape (`fetchMember(req)`-resolved). `immutable` defaults `true` (`update: () => false`). Opt-in `authorRankOrder` config absorbs `setAuthorRankOrder` (denormalizes the author's rank order at create time, fail-secures to `99999` on any resolution failure, falls back to a direct Rank lookup when the author's `rank` relationship is an unpopulated ID). `auditCreate`/`auditRead` seams absorb `auditNoteCreation`/`logReadAccess` — both fire-and-log-only, never block the write/read on failure. - **`registerPersonnelGdpr`** (new `src/gdpr.ts`, mirrors `@wabbit/tome-sc`'s `registerScGdpr`): registers `delegated-authority` as `retain`/post-identity (org history) and `personnel-notes` as `hard-delete`/pre-identity with a CUSTOM `onDelete` — the registry's built-in `userField`/`memberField` OR pair can't express "match one `memberId` against two DIFFERENT member-keyed fields" (`member` AND `author`), which is what a production consumer's real "both directions" erasure actually needs. `deleteAs: ['member', 'author']` (default: both) narrows to one direction if ever needed. Dispositions match the Wave 5 compliance plan §3a's own rows for these two collections verbatim. New test files `tests/delegated-authority.test.ts` (18 tests), `tests/personnel-notes.test.ts` (19 tests), `tests/personnel-gdpr.test.ts` (10 tests). Neither factory is wired into `createOrgLayer` (design principle 2 — individual factories, explicit slugs, same as Position/Rank/Promotion). **I6.1 — Promotion follow-up.** `Promotions/index.ts` (a production consumer's actual adapter over `createPromotionCollection`, `26c86583` on that consumer's integration branch) hit three more gaps, documented in that commit's adapter header and pinned by `promotionsHooksParity.spec.ts`: - **`extraFields`:** the only one of the six adoptable factories in this wave without this passthrough — that consumer's 7 no-factory-correlate fields (`authorizedBy`, `systemGenerated`, `generatedBy`, `triggeredByAcademy`, `attendingCeremonies`, `calledAtCeremony`, `calledAt`) had nowhere to go until now. - **`approvalModel: 'grouped'` completeness**, all additive-only under new `validateBypassField`/`groupedModelOverrides` options: `validateBypassField` adds the array-level `validate` bypassing "at least one recommendation required" when the named field (that consumer: `systemGenerated`) is `true`; `groupedModelOverrides.{recommendations,approval,denial}` add `admin.condition` per group/array (that consumer hides `approval`/`denial` until populated or `status` matches), a `beforeValidate` hook seam on `recommendations[].reason` (that consumer: link canonicalization + inline-data-URI guard), and a rich-text `editor` override for `denial.reason` (that consumer needs a richer node vocabulary to avoid a Lexical crash on member-facing content). - **`createPromotionNotificationCleanupHook`'s resolve-write**: `promotionsHooksParity.spec.ts` proved the hook's one-field write (`{ bucket: 'done' }`) was a strict SUBSET of that consumer's real `resolveNotificationByDedupKey`, which writes FOUR fields (`bucket`/`resolutionMode`/`resolvedAt`/`expiresAt`) — missing `expiresAt` specifically meant the 30-day retention cron would never sweep the row. New `resolveWrite` (default: today's one-field write) and `onResolved` (default: none; that consumer: socket emit + triage-cache bust, called per resolved row, errors caught and reported per-row without aborting the sweep) close the gap to parity. New test file `tests/wave6-i6-1.test.ts` (20 tests). `factory-snapshots.test.ts` and `wave6-i2-1-i6.test.ts` untouched in behavior and green — every default output (including the pinned `approvalModel: 'grouped'` field shapes) is unchanged. 243/243 org tests green; `tsc --noEmit` clean; build + `assert-node-loadable.mjs --every-file` 108/108 PASS.
v0.6.0minor

db62bee: Wave 6 org-adoption I2.1 + I6 — closes the four Division/Team factory gaps the real adoption by a production consumer hit, and makes `createPromotionCollection` adoptable. All opt-in, defaults byte-identical. **I2.1 — Division/Team follow-up.** `Wings/index.ts`/`Units/index.ts` (a production consumer's actual adapters over these factories) each documented a gap they had to work around locally instead of via config: - **`extraFields` (Division + Team):** neither factory had the passthrough Rank/Position already had — added, same contract. - **`leadershipAccess` (Division + Team):** field-level `access` for the position-mode `leadership` group — that consumer locks it behind `canManageHierarchy` and previously had no seam for that at all, forcing the whole group to be kept as its own object, verbatim. - **`divisionField` (Team):** the Division-backlink field name was hardcoded to `'division'` with no override, so that consumer's `'wing'` name could never be produced by the factory. Renaming it also renames the `admin.defaultColumns` entry and description. - **`leadershipFields.executiveOfficer`/`.seniorNCO` (Team):** the position-mode `leadership` group had only `leader` + `other[]` where that consumer's real shape has FOUR fields — the same 3-singular + other[] shape Division already had. Each new field is emitted ONLY when named, so the original 2-field default stays byte-identical. Division and Team now share the identical option surface. **I6 — Promotion adoptable.** - **`statusOptions` + `statusDefault`:** enums are config, not code — a production consumer's capitalized five (`Pending/Approved/Completed/Denied/Canceled`, 2663 live rows) replace Tome's lowercase five via the same `{mode, options}` contract as `Rank.categoryOptions`. Exported `DEFAULT_PROMOTION_STATUS_OPTIONS`. - **`fromRankRequired`** (default `false`) **+ `memberImmutable`** (default `false`, adds field-level `access.update: () => false` on `member`). - **`approvalModel: 'flat' | 'grouped'`** (default `'flat'`): `'grouped'` swaps the flat `recommendedBy`/`approvedBy` for a production consumer's real shape — a `recommendations[]` array (`recommendedBy` + `reason`) plus `approval` (`approvedBy`+`approvedAt`) and `denial` (`deniedBy`+`deniedAt`+`reason`) groups, field names read verbatim from `Promotions/index.ts`. - **`createPromotionNotificationCleanupHook`** (new `promotion-notification-cleanup.ts`): export-only afterDelete factory absorbing `resolvePromotionNotificationsOnDelete` (the 7-orphaned-tasks incident) — discovers open notifications by dedup-key PREFIX (the suffix set embeds a variable recipient id, so it isn't finite) with a `like`-then-real-`startsWith` re-check, then resolves each one. Collection slug, key field, status field, and the prefix function are all injectable. - **`createMemberRankSyncHook`** (new `member-rank-sync.ts`): export-only afterChange factory absorbing the CORE of `updateMemberRank` — writes `member.rank` (+ an optional last-promoted date field) when a promotion transitions to its completed status, WITH both of that consumer's re-entry guards ported as named, overridable context-flag options (`ceremonyClearContextFlag` default `'rankUpdateClearCeremonies'`, `reconcilerPassContextFlag` default `'reconcilerPass'`). Ceremony linking, onboarding auto-graduation, Discord sync, notifications, and cache revalidation stay consumer-local (events-wave territory) — chain a consumer hook after this one for those. - **`fourEyes`** (default `true`): gates the built-in `promotionFourEyesHook` off. That consumer enforces four-eyes itself at the application layer (`canApprovePromotion`'s self-block + submitter-block, also checked in `confirmSoftBan`) — running Tome's hook too would be a second, independent enforcement of the same rule against fields `approvalModel: 'grouped'` doesn't even populate. Both new hooks follow the same export-only, never-auto-wired, never-rethrows posture as the I2 structural hooks (`OrgSyncFailureReporter` injectable). New test file `tests/wave6-i2-1-i6.test.ts` (41 tests); `factory-snapshots.test.ts` and `wings-units-w6-i2.test.ts` untouched and green — every default output is unchanged.

  • db62bee: Wave 6 org-adoption I2.1 + I6 — closes the four Division/Team factory gaps the real adoption by a production consumer hit, and makes `createPromotionCollection` adoptable. All opt-in, defaults byte-identical. **I2.1 — Division/Team follow-up.** `Wings/index.ts`/`Units/index.ts` (a production consumer's actual adapters over these factories) each documented a gap they had to work around locally instead of via config: - **`extraFields` (Division + Team):** neither factory had the passthrough Rank/Position already had — added, same contract. - **`leadershipAccess` (Division + Team):** field-level `access` for the position-mode `leadership` group — that consumer locks it behind `canManageHierarchy` and previously had no seam for that at all, forcing the whole group to be kept as its own object, verbatim. - **`divisionField` (Team):** the Division-backlink field name was hardcoded to `'division'` with no override, so that consumer's `'wing'` name could never be produced by the factory. Renaming it also renames the `admin.defaultColumns` entry and description. - **`leadershipFields.executiveOfficer`/`.seniorNCO` (Team):** the position-mode `leadership` group had only `leader` + `other[]` where that consumer's real shape has FOUR fields — the same 3-singular + other[] shape Division already had. Each new field is emitted ONLY when named, so the original 2-field default stays byte-identical. Division and Team now share the identical option surface. **I6 — Promotion adoptable.** - **`statusOptions` + `statusDefault`:** enums are config, not code — a production consumer's capitalized five (`Pending/Approved/Completed/Denied/Canceled`, 2663 live rows) replace Tome's lowercase five via the same `{mode, options}` contract as `Rank.categoryOptions`. Exported `DEFAULT_PROMOTION_STATUS_OPTIONS`. - **`fromRankRequired`** (default `false`) **+ `memberImmutable`** (default `false`, adds field-level `access.update: () => false` on `member`). - **`approvalModel: 'flat' | 'grouped'`** (default `'flat'`): `'grouped'` swaps the flat `recommendedBy`/`approvedBy` for a production consumer's real shape — a `recommendations[]` array (`recommendedBy` + `reason`) plus `approval` (`approvedBy`+`approvedAt`) and `denial` (`deniedBy`+`deniedAt`+`reason`) groups, field names read verbatim from `Promotions/index.ts`. - **`createPromotionNotificationCleanupHook`** (new `promotion-notification-cleanup.ts`): export-only afterDelete factory absorbing `resolvePromotionNotificationsOnDelete` (the 7-orphaned-tasks incident) — discovers open notifications by dedup-key PREFIX (the suffix set embeds a variable recipient id, so it isn't finite) with a `like`-then-real-`startsWith` re-check, then resolves each one. Collection slug, key field, status field, and the prefix function are all injectable. - **`createMemberRankSyncHook`** (new `member-rank-sync.ts`): export-only afterChange factory absorbing the CORE of `updateMemberRank` — writes `member.rank` (+ an optional last-promoted date field) when a promotion transitions to its completed status, WITH both of that consumer's re-entry guards ported as named, overridable context-flag options (`ceremonyClearContextFlag` default `'rankUpdateClearCeremonies'`, `reconcilerPassContextFlag` default `'reconcilerPass'`). Ceremony linking, onboarding auto-graduation, Discord sync, notifications, and cache revalidation stay consumer-local (events-wave territory) — chain a consumer hook after this one for those. - **`fourEyes`** (default `true`): gates the built-in `promotionFourEyesHook` off. That consumer enforces four-eyes itself at the application layer (`canApprovePromotion`'s self-block + submitter-block, also checked in `confirmSoftBan`) — running Tome's hook too would be a second, independent enforcement of the same rule against fields `approvalModel: 'grouped'` doesn't even populate. Both new hooks follow the same export-only, never-auto-wired, never-rethrows posture as the I2 structural hooks (`OrgSyncFailureReporter` injectable). New test file `tests/wave6-i2-1-i6.test.ts` (41 tests); `factory-snapshots.test.ts` and `wings-units-w6-i2.test.ts` untouched and green — every default output is unchanged.
v0.5.0minor

ef3ef03: Wave 6 org-adoption I3 — `createPositionCollection` grows the config surface a production consumer's `Billets` needs to adopt it, and closes the wave's headline risk: the `authorityLevel` enum collision. - **`authorityLevel` collision closed at the root, via rename.** Tome's original `authorityLevel` field (`strategic/operational/tactical/technical`) was DESCRIPTIVE — a monorepo-wide grep (plus the starter and wabbit-site-core) found zero consumers of its value. The consumer's `Billets.authorityLevel` (`organization/cascading/direct/advisory`) is EXECUTABLE — read by `getLeadershipProfile`, `resolveLowestCommonAuthority`, and `checkActionPermission/*`. Same field name, disjoint vocabularies, one load-bearing: a naive merge would have silently disabled cascading authority with no error and no failing test. The fix is a rename, not a merge: the old field is now **`authorityTier`** (same four values, same meaning) — freeing `authorityLevel` for the executable semantics. **This is the one deliberate default-output change in this release**: `tests/factory-snapshots.test.ts`'s Position field-shape assertion now expects `authorityTier` in place of `authorityLevel`, updated deliberately in this same commit. Every other option below is opt-in with byte-identical defaults. - **`includeAuthorityLevel` (default `false`) + `authorityLevelOptions`:** adds a SEPARATE `authorityLevel` select carrying the consumer's four executable values verbatim (labels + admin description), exported as `DEFAULT_AUTHORITY_LEVEL_OPTIONS`. Tome adopts that consumer's behavior as the platform default for this field once a consumer opts in. - **`enforceScopeExclusivity` (default `false`) + `scopeFields` (default `['division', 'team']`):** absorbs the consumer's REAL `Billets/index.ts` beforeValidate hook, ported faithfully from the source (its integration branch) rather than the wave plan's looser paraphrase — it throws only when MORE THAN ONE scope anchor is set; zero is a valid org-wide position, matching that consumer's own inline comment ("or none for org-wide billets"). New `position-scope-exclusivity.ts`. - **`guardDeleteWhenReferenced` (default `false`):** absorbs `beforeDeleteBillet`'s S2 (refuses delete when a Division/Team running `leadershipMode: 'position'` references this Position in a leadership slot) + S3 (directly clears the holder's Member doc, bypassing any afterChange chain — same re-entrancy reasoning `rank-delete-guard.ts` already documents for Rank). Configurable `memberPositionField` (default `'position'`, that consumer: `'billet'`) and `divisionLeadershipFields`/`teamLeadershipFields` (same shape as `DivisionCollectionConfig`/`TeamCollectionConfig.leadershipFields`). New `position-delete-guard.ts`. - **`deriveDisplayTitle` (default `false`) + `composeDisplayTitle` override:** absorbs the consumer's `beforeChange` hook, which computes `isVacant` (`= !currentHolder`) and composes `displayTitle` together in one pass — both fields have existed since MVP with nothing computing them. Default composition is `"<scope name> — <title>"`; pass `composeDisplayTitle` to match that consumer's own `"<title>, <scope name>"` shape verbatim. New `position-derivations.ts`, exports `defaultComposeDisplayTitle`. - **`extraFields` passthrough**, same contract as Rank/Division/Team — for the consumer's `ship`, `requiredCertifications` (both stay consumer-local, no Tome correlate). `academyGrants`/`syncAcademyGrants` (ADR-012) stay consumer-local entirely — LMS-domain, not absorbed here. All three new hooks are wired directly into `createPositionCollection` behind their opt-in flags (not export-only) via `mergeHooks`, so a consumer's own hooks compose alongside them rather than replacing them — same pattern as Rank's `guardDeleteWhenHeld`.

  • ef3ef03: Wave 6 org-adoption I3 — `createPositionCollection` grows the config surface a production consumer's `Billets` needs to adopt it, and closes the wave's headline risk: the `authorityLevel` enum collision. - **`authorityLevel` collision closed at the root, via rename.** Tome's original `authorityLevel` field (`strategic/operational/tactical/technical`) was DESCRIPTIVE — a monorepo-wide grep (plus the starter and wabbit-site-core) found zero consumers of its value. The consumer's `Billets.authorityLevel` (`organization/cascading/direct/advisory`) is EXECUTABLE — read by `getLeadershipProfile`, `resolveLowestCommonAuthority`, and `checkActionPermission/*`. Same field name, disjoint vocabularies, one load-bearing: a naive merge would have silently disabled cascading authority with no error and no failing test. The fix is a rename, not a merge: the old field is now **`authorityTier`** (same four values, same meaning) — freeing `authorityLevel` for the executable semantics. **This is the one deliberate default-output change in this release**: `tests/factory-snapshots.test.ts`'s Position field-shape assertion now expects `authorityTier` in place of `authorityLevel`, updated deliberately in this same commit. Every other option below is opt-in with byte-identical defaults. - **`includeAuthorityLevel` (default `false`) + `authorityLevelOptions`:** adds a SEPARATE `authorityLevel` select carrying the consumer's four executable values verbatim (labels + admin description), exported as `DEFAULT_AUTHORITY_LEVEL_OPTIONS`. Tome adopts that consumer's behavior as the platform default for this field once a consumer opts in. - **`enforceScopeExclusivity` (default `false`) + `scopeFields` (default `['division', 'team']`):** absorbs the consumer's REAL `Billets/index.ts` beforeValidate hook, ported faithfully from the source (its integration branch) rather than the wave plan's looser paraphrase — it throws only when MORE THAN ONE scope anchor is set; zero is a valid org-wide position, matching that consumer's own inline comment ("or none for org-wide billets"). New `position-scope-exclusivity.ts`. - **`guardDeleteWhenReferenced` (default `false`):** absorbs `beforeDeleteBillet`'s S2 (refuses delete when a Division/Team running `leadershipMode: 'position'` references this Position in a leadership slot) + S3 (directly clears the holder's Member doc, bypassing any afterChange chain — same re-entrancy reasoning `rank-delete-guard.ts` already documents for Rank). Configurable `memberPositionField` (default `'position'`, that consumer: `'billet'`) and `divisionLeadershipFields`/`teamLeadershipFields` (same shape as `DivisionCollectionConfig`/`TeamCollectionConfig.leadershipFields`). New `position-delete-guard.ts`. - **`deriveDisplayTitle` (default `false`) + `composeDisplayTitle` override:** absorbs the consumer's `beforeChange` hook, which computes `isVacant` (`= !currentHolder`) and composes `displayTitle` together in one pass — both fields have existed since MVP with nothing computing them. Default composition is `"<scope name> — <title>"`; pass `composeDisplayTitle` to match that consumer's own `"<title>, <scope name>"` shape verbatim. New `position-derivations.ts`, exports `defaultComposeDisplayTitle`. - **`extraFields` passthrough**, same contract as Rank/Division/Team — for the consumer's `ship`, `requiredCertifications` (both stay consumer-local, no Tome correlate). `academyGrants`/`syncAcademyGrants` (ADR-012) stay consumer-local entirely — LMS-domain, not absorbed here. All three new hooks are wired directly into `createPositionCollection` behind their opt-in flags (not export-only) via `mergeHooks`, so a consumer's own hooks compose alongside them rather than replacing them — same pattern as Rank's `guardDeleteWhenHeld`.
v0.4.0minor

90f41ae: Wave 6 org-adoption I2 — `createDivisionCollection`/`createTeamCollection` grow an opt-in `leadershipMode` + `divisionRequired` surface, and four structural sync hooks absorbed from a production consumer's `Wings`/`Units`/`Members` production behavior are exported (never auto-wired). Every option below is opt-in; the existing `factory-snapshots.test.ts` Division/Team assertions stay green, proving default output is byte-identical. - **`leadershipMode: 'member' | 'position'`** (default `'member'`, today's `leader`/`deputy` member-relationship fields, unchanged). `'position'` drops those two fields and emits a `leadership` group of Position relationships instead — Division: `commanderPosition`/`executiveOfficerPosition`/`seniorNCOPosition`; Team: `leaderPosition`/`otherLeadershipPositions[]`. Field names are overridable via `leadershipFields` so that consumer can adopt with its existing `commanderBillet`/`executiveOfficerBillet`/`seniorNCOBillet`/`otherLeadershipBillets` names, no data rename. `positionSlug` threads the `relationTo` (default `ORG_DEFAULT_SLUGS.positions`). - **`Team.divisionRequired`** (default `true`, today's shape). That consumer's `units.wing` is nullable — wing deletion nulls the backlink rather than cascading — pass `false` to match. - **`createMemberCountSync({ memberSlug, divisionSlug, teamSlug, fields })`** — Member `afterChange`/`afterDelete` hooks that atomically sync `Division`/`Team.memberCount`, replacing the "not auto-synced" honesty gap those two fields' descriptions have carried since MVP. Counts the union of a member's hasMany array (`divisions`/`teams`) and singular primary field (`primaryDivision`/`primaryTeam`) so neither assignment convention silently undercounts. Uses a real atomic `$inc` when the DB adapter is Mongoose (duck-typed at runtime — this package has no `@payloadcms/db-mongodb` dependency), falling back to a documented, non-atomic read-modify-write otherwise. Export-only: a consumer opts in from their own Member collection's hooks. - **`createDivisionTeamReciprocityHook`**, **`createTeamSunsetHook`**, **`createDivisionCleanupHook`** / **`createTeamCleanupHook`** — exported factories porting the consumer's `afterWingChange` (de-dupe + reciprocal backlink, `preventRecursion`-guarded), `handleUnitSunset` (member/position cascade on an operational→sunset status flip), and `cleanupWingReferences`/`afterUnitDelete` (afterDelete reference cleanup) behavior, all fully configurable by slug/field name. All accept an injectable `onSyncFailure` reporter (that consumer: `reportSyncFailure`) and never rethrow — a failed cross-collection sync is reported, not allowed to fail the write that triggered it. None are auto-wired onto `createDivisionCollection`/`createTeamCollection`; see the README's "Adopting divisions/teams from an existing app" section for the hook ORDER requirement (consumer-side reference-reading cleanup, e.g. Discord, must run BEFORE the cleanup hooks in `afterDelete`). - **`includeAbbreviation` (Rank, default `true`)** and **`slugField` (Rank/Division/Team, default = today's built-in `slug` field; `false` omits it; `Field[]` replaces it verbatim)** — a same-PR follow-up. That consumer's ranks adapter (W6-I1 adopt) had to FILTER `createRankCollection`'s emitted fields post-hoc to drop `abbreviation` and swap the built-in `slug` field for its own `slugField('name')` pair; consumer-side editing of factory output isn't an adoption contract, so both are now first-class options. `slugField` is shared verbatim across Rank/Division/Team (identical built-in field shape, new `resolveSlugField.ts` helper); `includeAbbreviation` is Rank-only since Division/Team have no `abbreviation` field to gate.

  • 90f41ae: Wave 6 org-adoption I2 — `createDivisionCollection`/`createTeamCollection` grow an opt-in `leadershipMode` + `divisionRequired` surface, and four structural sync hooks absorbed from a production consumer's `Wings`/`Units`/`Members` production behavior are exported (never auto-wired). Every option below is opt-in; the existing `factory-snapshots.test.ts` Division/Team assertions stay green, proving default output is byte-identical. - **`leadershipMode: 'member' | 'position'`** (default `'member'`, today's `leader`/`deputy` member-relationship fields, unchanged). `'position'` drops those two fields and emits a `leadership` group of Position relationships instead — Division: `commanderPosition`/`executiveOfficerPosition`/`seniorNCOPosition`; Team: `leaderPosition`/`otherLeadershipPositions[]`. Field names are overridable via `leadershipFields` so that consumer can adopt with its existing `commanderBillet`/`executiveOfficerBillet`/`seniorNCOBillet`/`otherLeadershipBillets` names, no data rename. `positionSlug` threads the `relationTo` (default `ORG_DEFAULT_SLUGS.positions`). - **`Team.divisionRequired`** (default `true`, today's shape). That consumer's `units.wing` is nullable — wing deletion nulls the backlink rather than cascading — pass `false` to match. - **`createMemberCountSync({ memberSlug, divisionSlug, teamSlug, fields })`** — Member `afterChange`/`afterDelete` hooks that atomically sync `Division`/`Team.memberCount`, replacing the "not auto-synced" honesty gap those two fields' descriptions have carried since MVP. Counts the union of a member's hasMany array (`divisions`/`teams`) and singular primary field (`primaryDivision`/`primaryTeam`) so neither assignment convention silently undercounts. Uses a real atomic `$inc` when the DB adapter is Mongoose (duck-typed at runtime — this package has no `@payloadcms/db-mongodb` dependency), falling back to a documented, non-atomic read-modify-write otherwise. Export-only: a consumer opts in from their own Member collection's hooks. - **`createDivisionTeamReciprocityHook`**, **`createTeamSunsetHook`**, **`createDivisionCleanupHook`** / **`createTeamCleanupHook`** — exported factories porting the consumer's `afterWingChange` (de-dupe + reciprocal backlink, `preventRecursion`-guarded), `handleUnitSunset` (member/position cascade on an operational→sunset status flip), and `cleanupWingReferences`/`afterUnitDelete` (afterDelete reference cleanup) behavior, all fully configurable by slug/field name. All accept an injectable `onSyncFailure` reporter (that consumer: `reportSyncFailure`) and never rethrow — a failed cross-collection sync is reported, not allowed to fail the write that triggered it. None are auto-wired onto `createDivisionCollection`/`createTeamCollection`; see the README's "Adopting divisions/teams from an existing app" section for the hook ORDER requirement (consumer-side reference-reading cleanup, e.g. Discord, must run BEFORE the cleanup hooks in `afterDelete`). - **`includeAbbreviation` (Rank, default `true`)** and **`slugField` (Rank/Division/Team, default = today's built-in `slug` field; `false` omits it; `Field[]` replaces it verbatim)** — a same-PR follow-up. That consumer's ranks adapter (W6-I1 adopt) had to FILTER `createRankCollection`'s emitted fields post-hoc to drop `abbreviation` and swap the built-in `slug` field for its own `slugField('name')` pair; consumer-side editing of factory output isn't an adoption contract, so both are now first-class options. `slugField` is shared verbatim across Rank/Division/Team (identical built-in field shape, new `resolveSlugField.ts` helper); `includeAbbreviation` is Rank-only since Division/Team have no `abbreviation` field to gate.
v0.3.9patch

b5c324e: Wave 6 org-adoption I1 — `createRankCollection` grows the config surface a production consumer's `Ranks` collection needs to adopt it, with zero default-behavior change for existing consumers (starter, wabbit-site-core, other registry consumers). Every option below is opt-in; the default-config output is byte-identical, proven by the existing 27-assertion `factory-snapshots.test.ts` staying green plus 16 new option-specific tests. - **`access` full override:** already possible via `BaseOrgCollectionConfig` spreading `config.access` after the factory's defaults, but undocumented and untested for Rank specifically. The consumer needs `read: authenticated` in place of the default `read: anyone` — a real posture change (public rosters vs. members-only), not a cosmetic one, so it's called out explicitly here rather than left to an implicit spread. - **`categoryOptions` + `categoryRequired`:** the `category` select's 8 generic values had zero overlap with the consumer's 11 lore values, and the field wasn't `required` while the consumer's is. New `categoryOptions?: { mode: 'extend' | 'replace', options }` (same contract as `@wabbit/tome-core/gdpr/compliance`'s `dataCategoryOptions`, ported to a new `packages/org/src/optionOverrides.ts` helper rather than importing the GDPR-domain module) lets a consumer extend or replace the vocabulary; `categoryRequired?: boolean` (default `false`, today's shape) flips requiredness. Defaults exported as `DEFAULT_RANK_CATEGORY_OPTIONS`. - **`insigniaTextField` / `insigniaImageField`:** field-name overrides so a consumer adopting this factory over an EXISTING table doesn't have to rename data. The consumer's collection spells these `insigniatext` (lowercase `t` — a one-character, silent-null-risk mismatch the wave plan flagged) and `image`; defaults stay `insigniaText` / `insignia`. A consumer renaming either field owns keeping their own readers (queries, populate hints, `defaultColumns`) consistent with the configured name — the factory does not alias the default name alongside a renamed one. - **`extraFields` passthrough:** appended after the standard field set, for the consumer's `discordRoleId`, `promotionPrerequisites`, `ceremonyScript`, and legacy id fields. - **`guardDeleteWhenHeld` (default `false`):** the factory's original header deferred a "before-delete hook prevents deletion when members hold the rank" guard to MVP+1 to avoid coupling Rank to Member in a delete-path hook loop before any consumer needed it. This is that deferred capability, absorbed from the consumer's `Ranks/hooks` beforeDelete guard (`packages/org/src/hooks/rank-delete-guard.ts`) — a `payload.count` read against the configured `memberSlug`/`memberRankField`, never a write, so it cannot loop. Throws a `400 APIError` naming the holder count when any Member still references the rank; passes through silently at zero holders. Runs through `mergeHooks`, so a consumer's own `beforeDelete`/`afterChange` hooks compose alongside it rather than replacing it. Keeps the consumer's `insigniatext` spelling via an upstream field-name option rather than forcing a rename.

  • b5c324e: Wave 6 org-adoption I1 — `createRankCollection` grows the config surface a production consumer's `Ranks` collection needs to adopt it, with zero default-behavior change for existing consumers (starter, wabbit-site-core, other registry consumers). Every option below is opt-in; the default-config output is byte-identical, proven by the existing 27-assertion `factory-snapshots.test.ts` staying green plus 16 new option-specific tests. - **`access` full override:** already possible via `BaseOrgCollectionConfig` spreading `config.access` after the factory's defaults, but undocumented and untested for Rank specifically. The consumer needs `read: authenticated` in place of the default `read: anyone` — a real posture change (public rosters vs. members-only), not a cosmetic one, so it's called out explicitly here rather than left to an implicit spread. - **`categoryOptions` + `categoryRequired`:** the `category` select's 8 generic values had zero overlap with the consumer's 11 lore values, and the field wasn't `required` while the consumer's is. New `categoryOptions?: { mode: 'extend' | 'replace', options }` (same contract as `@wabbit/tome-core/gdpr/compliance`'s `dataCategoryOptions`, ported to a new `packages/org/src/optionOverrides.ts` helper rather than importing the GDPR-domain module) lets a consumer extend or replace the vocabulary; `categoryRequired?: boolean` (default `false`, today's shape) flips requiredness. Defaults exported as `DEFAULT_RANK_CATEGORY_OPTIONS`. - **`insigniaTextField` / `insigniaImageField`:** field-name overrides so a consumer adopting this factory over an EXISTING table doesn't have to rename data. The consumer's collection spells these `insigniatext` (lowercase `t` — a one-character, silent-null-risk mismatch the wave plan flagged) and `image`; defaults stay `insigniaText` / `insignia`. A consumer renaming either field owns keeping their own readers (queries, populate hints, `defaultColumns`) consistent with the configured name — the factory does not alias the default name alongside a renamed one. - **`extraFields` passthrough:** appended after the standard field set, for the consumer's `discordRoleId`, `promotionPrerequisites`, `ceremonyScript`, and legacy id fields. - **`guardDeleteWhenHeld` (default `false`):** the factory's original header deferred a "before-delete hook prevents deletion when members hold the rank" guard to MVP+1 to avoid coupling Rank to Member in a delete-path hook loop before any consumer needed it. This is that deferred capability, absorbed from the consumer's `Ranks/hooks` beforeDelete guard (`packages/org/src/hooks/rank-delete-guard.ts`) — a `payload.count` read against the configured `memberSlug`/`memberRankField`, never a write, so it cannot loop. Throws a `400 APIError` naming the holder count when any Member still references the rank; passes through silently at zero holders. Runs through `mergeHooks`, so a consumer's own `beforeDelete`/`afterChange` hooks compose alongside it rather than replacing it. Keeps the consumer's `insigniatext` spelling via an upstream field-name option rather than forcing a rename.
v0.3.8patch

1275810: Wave 6 org-adoption preflight (W6-I0) — upstream hygiene with zero default-behavior change for existing consumers (starter, wabbit-site-core, other registry consumers). - **Stale layer version fixed:** `registerLayer('@wabbit/tome-org', …)` hardcoded `version: '0.2.3'` while the package had shipped as far as 0.3.7 — five patches stale, so any consumer gating on the registry saw an out-of-date contract. Now reads `ORG_LAYER_VERSION` from `src/version.ts`, pinned to `package.json` by `tests/layer-version.test.ts` (mirrors the `@wabbit/tome-lms` / `@wabbit/tome-accounts` pattern). - **Hook-replacement bug fixed:** every factory that merged `config.hooks` with `{ ...builtInDefaults, ...(config.hooks ?? {}) }` silently DROPPED a built-in hook whenever a consumer set the same key — confirmed live in `Membership.ts` (a consumer `afterChange` would drop the `memberCount` sync) and `Promotion.ts` (a consumer `beforeValidate` would drop the four-eyes guard). All 12 collection factories now merge through a new `mergeHooks(base, extra)` helper (`src/hooks/mergeHooks.ts`, ported from `packages/sc/src/extensions/mergeHooks.ts` — no shared core leaf subpath exists yet, tracked as a follow-up) that APPENDS per hook key instead of replacing. `mergeHooks` is exported from the package barrel. - **`member-collection` slot claim gap documented and closed:** audited whether `resolveMemberSlug` falls back to the wrong slug when a consumer calls the individual factories (design principle 2's required shape for the real adoption by a production consumer) instead of `createOrgLayer()`. Finding: the slot claim is currently READ BY NOTHING in the monorepo and cannot even carry a slug value (`LayerSlotClaim` is `{slot, claimant, claimedAt}`) — `resolveMemberSlug` resolves purely from the `memberSlug`/`userSlug` config passed directly to each factory, entirely decoupled from the slot registry. So today there is zero functional risk. The gap that IS real: factory-only consumers never claim the slot, so the registry's ownership record is silently incomplete for exactly the consumption shape later increments will use. Added `claimOrgMemberSlot()` (exported from the package barrel) for those consumers to call explicitly; documented in the README. - **New factory snapshot tests** (`tests/factory-snapshots.test.ts`, 27 assertions): pins the default-config field names/types/required flags and hook-array shape for all 8 structure/personnel factories (Division, Team, Squad, Position, Membership, Member, Rank, Promotion) plus two regression tests proving a consumer hook now appends instead of replacing. No such test existed before this package's only prior test (`permission-convergence.test.ts`) exercised the access engine, not the factories. - **False "auto-synced" claim corrected:** `Division.memberCount` / `Team.memberCount` admin descriptions claimed the field auto-syncs; `hooks/member-count.ts` only ever wired the sync for Squad (`Membership.afterChange`/`afterDelete`). Descriptions now say so honestly. The actual Division/Team sync remains unimplemented — deferred to W6-I2 per the Wave 6 org plan. - **`Membership.entityType: 'squadron'` dead-option gap fixed, opt-in:** the `entityType` select has offered `'squadron'` since the MVP spec with no backing relationship field, so a saved `squadron` row has never referenced anything. Added an OPT-IN `squadronSlug?: string` config (`MembershipCollectionConfig`) that, when passed, adds a `squadron` relationship field gated on `entityType === 'squadron'`. Left opt-in rather than defaulted: a hardcoded `relationTo` would point at a collection slug (e.g. `@wabbit/tome-sc`'s `squadrons`) that may not exist in every consumer's `payload.config.ts`, which Payload's `sanitizeConfig` rejects at boot — so a forced default would have been the actually-breaking choice. Omitting `squadronSlug` reproduces today's exact (broken) behavior. Fully backward compatible: every new config key is optional and every changed default-config output is byte-identical to before, proven by the new snapshot suite.

  • 1275810: Wave 6 org-adoption preflight (W6-I0) — upstream hygiene with zero default-behavior change for existing consumers (starter, wabbit-site-core, other registry consumers). - **Stale layer version fixed:** `registerLayer('@wabbit/tome-org', …)` hardcoded `version: '0.2.3'` while the package had shipped as far as 0.3.7 — five patches stale, so any consumer gating on the registry saw an out-of-date contract. Now reads `ORG_LAYER_VERSION` from `src/version.ts`, pinned to `package.json` by `tests/layer-version.test.ts` (mirrors the `@wabbit/tome-lms` / `@wabbit/tome-accounts` pattern). - **Hook-replacement bug fixed:** every factory that merged `config.hooks` with `{ ...builtInDefaults, ...(config.hooks ?? {}) }` silently DROPPED a built-in hook whenever a consumer set the same key — confirmed live in `Membership.ts` (a consumer `afterChange` would drop the `memberCount` sync) and `Promotion.ts` (a consumer `beforeValidate` would drop the four-eyes guard). All 12 collection factories now merge through a new `mergeHooks(base, extra)` helper (`src/hooks/mergeHooks.ts`, ported from `packages/sc/src/extensions/mergeHooks.ts` — no shared core leaf subpath exists yet, tracked as a follow-up) that APPENDS per hook key instead of replacing. `mergeHooks` is exported from the package barrel. - **`member-collection` slot claim gap documented and closed:** audited whether `resolveMemberSlug` falls back to the wrong slug when a consumer calls the individual factories (design principle 2's required shape for the real adoption by a production consumer) instead of `createOrgLayer()`. Finding: the slot claim is currently READ BY NOTHING in the monorepo and cannot even carry a slug value (`LayerSlotClaim` is `{slot, claimant, claimedAt}`) — `resolveMemberSlug` resolves purely from the `memberSlug`/`userSlug` config passed directly to each factory, entirely decoupled from the slot registry. So today there is zero functional risk. The gap that IS real: factory-only consumers never claim the slot, so the registry's ownership record is silently incomplete for exactly the consumption shape later increments will use. Added `claimOrgMemberSlot()` (exported from the package barrel) for those consumers to call explicitly; documented in the README. - **New factory snapshot tests** (`tests/factory-snapshots.test.ts`, 27 assertions): pins the default-config field names/types/required flags and hook-array shape for all 8 structure/personnel factories (Division, Team, Squad, Position, Membership, Member, Rank, Promotion) plus two regression tests proving a consumer hook now appends instead of replacing. No such test existed before this package's only prior test (`permission-convergence.test.ts`) exercised the access engine, not the factories. - **False "auto-synced" claim corrected:** `Division.memberCount` / `Team.memberCount` admin descriptions claimed the field auto-syncs; `hooks/member-count.ts` only ever wired the sync for Squad (`Membership.afterChange`/`afterDelete`). Descriptions now say so honestly. The actual Division/Team sync remains unimplemented — deferred to W6-I2 per the Wave 6 org plan. - **`Membership.entityType: 'squadron'` dead-option gap fixed, opt-in:** the `entityType` select has offered `'squadron'` since the MVP spec with no backing relationship field, so a saved `squadron` row has never referenced anything. Added an OPT-IN `squadronSlug?: string` config (`MembershipCollectionConfig`) that, when passed, adds a `squadron` relationship field gated on `entityType === 'squadron'`. Left opt-in rather than defaulted: a hardcoded `relationTo` would point at a collection slug (e.g. `@wabbit/tome-sc`'s `squadrons`) that may not exist in every consumer's `payload.config.ts`, which Payload's `sanitizeConfig` rejects at boot — so a forced default would have been the actually-breaking choice. Omitting `squadronSlug` reproduces today's exact (broken) behavior. Fully backward compatible: every new config key is optional and every changed default-config output is byte-identical to before, proven by the new snapshot suite.
v0.3.7patch

7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.

  • 7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
v0.3.6patch

dbac581: Import `authenticated`/`anyone` from the `@wabbit/tome-core/auth/guards` leaf instead of the `/auth` barrel. The barrel statically re-exports `createBetterAuth`, which drags `@delmaredigital/payload-better-auth` into the module graph — so any consumer loading this package outside a bundler (payload CLI under tsx: `generate:types`, seeds, migrations) crashed with ERR_MODULE_NOT_FOUND unless it installed an auth stack it may deliberately not use. Leaf-import discipline is what this platform's own consumer docs mandate; the layers now follow it themselves. No behavioral change — same functions, same leaf they always resolved to.

  • dbac581: Import `authenticated`/`anyone` from the `@wabbit/tome-core/auth/guards` leaf instead of the `/auth` barrel. The barrel statically re-exports `createBetterAuth`, which drags `@delmaredigital/payload-better-auth` into the module graph — so any consumer loading this package outside a bundler (payload CLI under tsx: `generate:types`, seeds, migrations) crashed with ERR_MODULE_NOT_FOUND unless it installed an auth stack it may deliberately not use. Leaf-import discipline is what this platform's own consumer docs mandate; the layers now follow it themselves. No behavioral change — same functions, same leaf they always resolved to.
v0.3.5patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.3.4patch

6779aa1: Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration). - **blocks-org-pack:** CampaignBanner's `codename` field is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGrid `rank` fields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout. - **tome-org:** flavored admin LABELS get neutral text while stored values stay put — Event status `boarding`/`debrief` labeled "Check-In"/"Wrap-Up"; eventType `operation`/`patrol`/`exam` labeled "Initiative"/"Outreach"/"Assessment"; `securityLevel` labeled "Access"; Campaign `codename` labeled "Internal Name" and campaignType `recurring_op`/`special_operation`/`deployment` labeled "Recurring Series"/"Special Initiative"/"Rollout"; Member `classification` labeled "Directory Visibility" with `classified` labeled "Private", "Chain of command" → "Reporting line"; Rank `securityClearance` labeled "Access Level" and category `command` labeled "Management"; Squad squadType `fire_team`/`flight` labeled "Crew"/"Pod", `callsign` labeled "Nickname"; Membership `squadron` labeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", category `command` labeled "Executive". The configurable `DEFAULT_ORG_TERMINOLOGY` (Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and `@wabbit/tome-sc` inherits it for its themed collections. - **blocks-core:** BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).

  • 6779aa1: Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration). - **blocks-org-pack:** CampaignBanner's `codename` field is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGrid `rank` fields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout. - **tome-org:** flavored admin LABELS get neutral text while stored values stay put — Event status `boarding`/`debrief` labeled "Check-In"/"Wrap-Up"; eventType `operation`/`patrol`/`exam` labeled "Initiative"/"Outreach"/"Assessment"; `securityLevel` labeled "Access"; Campaign `codename` labeled "Internal Name" and campaignType `recurring_op`/`special_operation`/`deployment` labeled "Recurring Series"/"Special Initiative"/"Rollout"; Member `classification` labeled "Directory Visibility" with `classified` labeled "Private", "Chain of command" → "Reporting line"; Rank `securityClearance` labeled "Access Level" and category `command` labeled "Management"; Squad squadType `fire_team`/`flight` labeled "Crew"/"Pod", `callsign` labeled "Nickname"; Membership `squadron` labeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", category `command` labeled "Executive". The configurable `DEFAULT_ORG_TERMINOLOGY` (Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and `@wabbit/tome-sc` inherits it for its themed collections. - **blocks-core:** BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).
v0.3.3patch

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: `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.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.3.2patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
v0.3.1patch

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.
v0.3.0minor

1a5e085: Converge onto the single platform permission engine. `tome-org` now registers `ORG_PERMISSIONS` and its super-permission map (translated to value-space) into `@wabbit/tome-core/auth/permissions` and delegates `checkOrgRole`/`hasPermission` to the shared engine — the parallel org permission resolver is removed (no more two-systems redundancy). Public API is unchanged and verified to resolve identically to the prior implementation across representative role fixtures (a production-consumer-shaped `role.permissions` map, multi-role, unpopulated/string-id roles, null user) plus super-permission implication. Adds exports `registerOrgPermissions`, `buildOrgSuperPermissionMap`, `ORG_OVERRIDABLE_PERMISSIONS`.

  • 1a5e085: Converge onto the single platform permission engine. `tome-org` now registers `ORG_PERMISSIONS` and its super-permission map (translated to value-space) into `@wabbit/tome-core/auth/permissions` and delegates `checkOrgRole`/`hasPermission` to the shared engine — the parallel org permission resolver is removed (no more two-systems redundancy). Public API is unchanged and verified to resolve identically to the prior implementation across representative role fixtures (a production-consumer-shaped `role.permissions` map, multi-role, unpopulated/string-id roles, null user) plus super-permission implication. Adds exports `registerOrgPermissions`, `buildOrgSuperPermissionMap`, `ORG_OVERRIDABLE_PERMISSIONS`.
v0.2.6patch

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • 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.
v0.2.5patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.2.4patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v0.2.0minor

52c6bcb: **Publish pipeline setup — first-publish prep.** Adds tsup config, dist build script with NODE_OPTIONS heap bump, publishConfig (restricted, npm.wabbit.com), main/module/types fields, files allowlist (`dist`, `README.md`, `LICENSE.md`), and exports map pointing at `dist/`. Mirrors the canonical `@wabbit/tome-*` publish template (catalog/economy/admin shape). **Peer-dep correction:** moves `@wabbit/tome-core` from `dependencies` (which was incorrect for a peer) to `peerDependencies` (`workspace:*`). Also keeps it in `devDependencies` so workspace install still resolves it at build time. Existing `peerDependenciesMeta` block was already declaring `@wabbit/tome-core` as a peer, so this fixes the orphan declaration. devDeps gains `cross-env`, `rimraf`, and `tsup` to match the canonical template. **One source change to make the published `.d.ts` consumable.** Annotated `createMemberCollection` return type as `CollectionConfig` (it was the lone factory without an explicit return type — the other 12 already had it). Without the annotation, tsup's dts rollup couldn't resolve some Payload internal subpath types referenced by the inferred wide return type and emitted literal `import 'node_modules/payload/dist/...'` paths in the d.ts that would 404 from a published consumer. This is the same pattern catalog/economy already follow; matches the discipline in `feedback_payload_config_typed_for_callback_inference`. Public API surface (collections, access helpers, hooks, factory, terminology, permissions) is otherwise unchanged. **Why now:** unblocks the agency-stack roadmap (`@wabbit/tome-crm`, `@wabbit/tome-deals`) — those layers depend on `tome-org` and consumer registry consumption requires `tome-org` to be on Verdaccio. Same template that catalog and economy got on 2026-04-27.

  • 52c6bcb: **Publish pipeline setup — first-publish prep.** Adds tsup config, dist build script with NODE_OPTIONS heap bump, publishConfig (restricted, npm.wabbit.com), main/module/types fields, files allowlist (`dist`, `README.md`, `LICENSE.md`), and exports map pointing at `dist/`. Mirrors the canonical `@wabbit/tome-*` publish template (catalog/economy/admin shape). **Peer-dep correction:** moves `@wabbit/tome-core` from `dependencies` (which was incorrect for a peer) to `peerDependencies` (`workspace:*`). Also keeps it in `devDependencies` so workspace install still resolves it at build time. Existing `peerDependenciesMeta` block was already declaring `@wabbit/tome-core` as a peer, so this fixes the orphan declaration. devDeps gains `cross-env`, `rimraf`, and `tsup` to match the canonical template. **One source change to make the published `.d.ts` consumable.** Annotated `createMemberCollection` return type as `CollectionConfig` (it was the lone factory without an explicit return type — the other 12 already had it). Without the annotation, tsup's dts rollup couldn't resolve some Payload internal subpath types referenced by the inferred wide return type and emitted literal `import 'node_modules/payload/dist/...'` paths in the d.ts that would 404 from a published consumer. This is the same pattern catalog/economy already follow; matches the discipline in `feedback_payload_config_typed_for_callback_inference`. Public API surface (collections, access helpers, hooks, factory, terminology, permissions) is otherwise unchanged. **Why now:** unblocks the agency-stack roadmap (`@wabbit/tome-crm`, `@wabbit/tome-deals`) — those layers depend on `tome-org` and consumer registry consumption requires `tome-org` to be on Verdaccio. Same template that catalog and economy got on 2026-04-27.
v0.1.1patch

Updated dependencies - @wabbit/tome-core@0.2.0

  • Updated dependencies - @wabbit/tome-core@0.2.0

Accounts

v0.5.3
v0.5.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.5.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.5.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.5.0minor

ff684db: `createAccountsLayer` now accepts `invitations: true`, adding an `account-invitations` collection and helpers to invite, accept, revoke and resend email invitations. Invite tokens are generated from a CSPRNG, returned once and stored only as a SHA-256 digest. Accepting is single-use (atomic compare-and-set), checks expiry (7-day default, configurable with `ttlDays`) and revocation, and requires the invited email unless `requireEmailMatch: false`. Roles are consumer-defined; only seats holding the configured `ownerRoles` can create, revoke, resend or list invitations. A removed seat can now be re-invited: the seat machine allows `removed -> invited`, and accepting re-admits the existing row. `createMembershipCollection` gains an `extraRoles` option for consumer-defined roles. Every option is off by default and existing behavior is unchanged.

  • ff684db: `createAccountsLayer` now accepts `invitations: true`, adding an `account-invitations` collection and helpers to invite, accept, revoke and resend email invitations. Invite tokens are generated from a CSPRNG, returned once and stored only as a SHA-256 digest. Accepting is single-use (atomic compare-and-set), checks expiry (7-day default, configurable with `ttlDays`) and revocation, and requires the invited email unless `requireEmailMatch: false`. Roles are consumer-defined; only seats holding the configured `ownerRoles` can create, revoke, resend or list invitations. A removed seat can now be re-invited: the seat machine allows `removed -> invited`, and accepting re-admits the existing row. `createMembershipCollection` gains an `extraRoles` option for consumer-defined roles. Every option is off by default and existing behavior is unchanged.
  • e827eb3: Security: owner seats and account billing state are now protected over the public API. Access-checked writes that used to succeed are now refused. `MANAGE_OWNER` was defined but never enforced, so any role with `MANAGE_MEMBERS` (`admin` by default) could `PATCH` its own seat to `owner` and then remove the real owner. Membership writes now need `MANAGE_OWNER` to create or update a seat to an owner role, to change, suspend, remove or delete a seat that holds one, and a seat can no longer be moved to another account. The last active owner seat cannot be removed, suspended, demoted or deleted through the API. Owner roles come from the new `ownerRoles` option on `createMembershipCollection`; `createAccountsLayer` reuses `invitations.ownerRoles` for it (default `['owner']`). `MANAGE_MEMBERS` behavior for non-owner seats is unchanged, and so are platform operators. On the Account collection, `MANAGE_ACCOUNT` no longer covers lifecycle and billing state. `billing`, `stripeCustomerId` and `stripeSubscriptionId` are refused for every access-checked create and update, `status` is writable only by platform operators, and `ownerMember` needs `MANAGE_OWNER`. `name`, `slug` and `type` stay editable. Server code that writes these fields (billing webhooks, provisioning, lifecycle helpers) must use `overrideAccess: true`, which bypasses all of the above. Create-time gaps closed on the same fields. `billing` (the group and every subfield, including `billing.provider`) is now locked on create as well as update, so an API caller can no longer POST an account with `billing.provider: 'manual'` and have entitlement checks read it as paid; a denied provider falls back to the `'stripe'` default. `status` on create is platform-operator-only (anyone else gets the `'active'` default), and `ownerMember` on create must be the creating user's own member profile, or the caller must be a platform operator. A create naming another member is refused by the field's `required` check. Provisioning flows that create accounts on someone's behalf must use `overrideAccess: true`.
v0.4.4patch

0fad0e7: README: document the default `accounts` slug collision with better-auth's `account` model — what the boot-time guard reports and the two one-sided resolutions (`slugs.accounts` on this layer, or renaming the better-auth model in tome-core's auth factory).

  • 0fad0e7: README: document the default `accounts` slug collision with better-auth's `account` model — what the boot-time guard reports and the two one-sided resolutions (`slugs.accounts` on this layer, or renaming the better-auth model in tome-core's auth factory).
v0.4.3patch

d99744d: The platform-operator bypass now reads legacy role slugs with core's role reader instead of the deprecated `checkRole`; who bypasses account scoping is unchanged. An empty `platformAdminRoles` still disables the legacy leg. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.

  • d99744d: The platform-operator bypass now reads legacy role slugs with core's role reader instead of the deprecated `checkRole`; who bypasses account scoping is unchanged. An empty `platformAdminRoles` still disables the legacy leg. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
v0.4.2patch

30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.

  • 30bdd74: `autoSlugHook` is now a typed delegate to core's `autoSlugFieldHook`, with an identical body. Two domain layers cannot import each other, so the shared body moved down to core.
  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`.
v0.4.1patch

bc386c0: Fix a silent Payload collection-slug collision between `createBetterAuth()` and `createAccountsLayer()` that broke every sign-up on a consumer mounting both at their defaults. **Root cause:** better-auth's internal `account` model and `@wabbit/tome-accounts`' tenant Account collection both default to the Payload slug `accounts`. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose required `name`/`slug`/`ownerMember` fields (from tome-accounts) were never supplied by better-auth's own `linkAccount`/`createAccount` calls — every `POST /api/auth/sign-up/email` returned a 500 naming those fields, not the collision that caused them. **The fix (`@wabbit/tome-core`):** - `registerLayer` (`utilities/layerRegistry`) now checks a newly-registering layer's `collections` against every already-registered layer's `collections` and throws a new `LayerCollectionSlugCollisionError` — naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside a `isLayerCollectionSlugCollisionError` type guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it. - `createBetterAuth()` now registers the Payload collection slugs it mounts (`@wabbit/tome-core-auth`) with this guard, and its own try/catch re-throws a collision instead of swallowing it. - New opt-in `internalModelNames.account` on `createBetterAuth()` renames better-auth's internal account model (e.g. `{ account: 'authAccount' }` → Payload slug `authAccounts`) — the auth-side resolution when a collision fires. Default is unchanged (`accounts`), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existing `accounts` table/collection first — see the option's JSDoc. **The fix (`@wabbit/tome-accounts`):** `createAccountsLayer()`'s existing `registerLayer` try/catch now re-throws a genuine `LayerCollectionSlugCollisionError` instead of swallowing it as a duplicate-registration no-op. The layer's existing `slugs: { accounts: '<other-slug>' }` override is the accounts-side resolution — pick ONE side, not both. **Backward compatibility:** a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (`accounts` on each side) are unchanged; the guard only fires when two layers genuinely collide.

  • bc386c0: Fix a silent Payload collection-slug collision between `createBetterAuth()` and `createAccountsLayer()` that broke every sign-up on a consumer mounting both at their defaults. **Root cause:** better-auth's internal `account` model and `@wabbit/tome-accounts`' tenant Account collection both default to the Payload slug `accounts`. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose required `name`/`slug`/`ownerMember` fields (from tome-accounts) were never supplied by better-auth's own `linkAccount`/`createAccount` calls — every `POST /api/auth/sign-up/email` returned a 500 naming those fields, not the collision that caused them. **The fix (`@wabbit/tome-core`):** - `registerLayer` (`utilities/layerRegistry`) now checks a newly-registering layer's `collections` against every already-registered layer's `collections` and throws a new `LayerCollectionSlugCollisionError` — naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside a `isLayerCollectionSlugCollisionError` type guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it. - `createBetterAuth()` now registers the Payload collection slugs it mounts (`@wabbit/tome-core-auth`) with this guard, and its own try/catch re-throws a collision instead of swallowing it. - New opt-in `internalModelNames.account` on `createBetterAuth()` renames better-auth's internal account model (e.g. `{ account: 'authAccount' }` → Payload slug `authAccounts`) — the auth-side resolution when a collision fires. Default is unchanged (`accounts`), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existing `accounts` table/collection first — see the option's JSDoc. **The fix (`@wabbit/tome-accounts`):** `createAccountsLayer()`'s existing `registerLayer` try/catch now re-throws a genuine `LayerCollectionSlugCollisionError` instead of swallowing it as a duplicate-registration no-op. The layer's existing `slugs: { accounts: '<other-slug>' }` override is the accounts-side resolution — pick ONE side, not both. **Backward compatibility:** a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (`accounts` on each side) are unchanged; the guard only fires when two layers genuinely collide.
v0.4.0minor

1339d61: Additive `billing` group on the Account collection (`provider`, `customerRef`, `subscriptionRef`, `updatedAt`), plus `resolveAccountBillingRefs(account)` to read it. `Account.ts`'s `stripeCustomerId`/`stripeSubscriptionId` carry no provider tag, so a consumer resolving entitlements against a second billing provider (Authorize.net, a staff-stamped `manual` invoice) cannot tell which provider those refs belong to. The legacy fields are kept and marked deprecated (a rename would be a platform-wide breaking migration to unblock one consuming layer) rather than removed; `resolveAccountBillingRefs` reads `billing` first and falls back to the legacy Stripe-named fields, tagging the result `source: 'billing' | 'legacy' | 'none'` so a caller — or a future migration sweep — can tell which shape answered. New export, hence the minor.

  • 1339d61: Additive `billing` group on the Account collection (`provider`, `customerRef`, `subscriptionRef`, `updatedAt`), plus `resolveAccountBillingRefs(account)` to read it. `Account.ts`'s `stripeCustomerId`/`stripeSubscriptionId` carry no provider tag, so a consumer resolving entitlements against a second billing provider (Authorize.net, a staff-stamped `manual` invoice) cannot tell which provider those refs belong to. The legacy fields are kept and marked deprecated (a rename would be a platform-wide breaking migration to unblock one consuming layer) rather than removed; `resolveAccountBillingRefs` reads `billing` first and falls back to the legacy Stripe-named fields, tagging the result `source: 'billing' | 'legacy' | 'none'` so a caller — or a future migration sweep — can tell which shape answered. New export, hence the minor.
v0.3.3patch

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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.

  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behavior changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
v0.3.2patch

Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).

  • Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).
v0.3.1patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.3.0minor

3087d61: Platform operators can see tenant accounts again — `accountScopedRead`, `accountMember` and `accountPermission` now honor a super-admin bypass. Every gate in `access/accountAccess.ts` asked one question: does the viewer have standing INSIDE this account. That is correct for tenant users and wrong for the operator running the platform, who is a member of no customer account — so a super-admin's own admin panel filtered out every customer's Account, Membership and Project. The failure was silent: the list rendered EMPTY rather than forbidden, which reads as "the row was never created" and sends you debugging a provisioning hook that is working fine. The bypass mirrors `orgScoped` / `vendorScoped` in `@wabbit/tome-core` (capability OR legacy-role, additive), so the platform keeps one way of saying "an operator outranks tenant scoping": - `platformAdminRoles` (default `['super-admin', 'admin']`) — the legacy flat `user.role` leg. - `platformAdminCapability` (default `'accounts:manage'`) — checked via `canAsync`, which hydrates a `users.roles` RELATIONSHIP at depth 1 and treats a `super-admin` slug as an implicit `'*'` grant. Sites whose roles live in the relation (rather than the flat field) are covered by this leg with no configuration. - `platformAdminBypass: false` — opt out entirely, for deployments where operators must not read tenant data. `accountScopedRead` returns `true` rather than a `Where` for an operator, deliberately: an operator must also see rows whose account relationship is null or orphaned, which no `{account: {in: [...]}}` filter would ever match — and those are precisely the rows worth looking at when provisioning has gone wrong. No behavior change for tenant users: non-admins are scoped exactly as before.

  • 3087d61: Platform operators can see tenant accounts again — `accountScopedRead`, `accountMember` and `accountPermission` now honor a super-admin bypass. Every gate in `access/accountAccess.ts` asked one question: does the viewer have standing INSIDE this account. That is correct for tenant users and wrong for the operator running the platform, who is a member of no customer account — so a super-admin's own admin panel filtered out every customer's Account, Membership and Project. The failure was silent: the list rendered EMPTY rather than forbidden, which reads as "the row was never created" and sends you debugging a provisioning hook that is working fine. The bypass mirrors `orgScoped` / `vendorScoped` in `@wabbit/tome-core` (capability OR legacy-role, additive), so the platform keeps one way of saying "an operator outranks tenant scoping": - `platformAdminRoles` (default `['super-admin', 'admin']`) — the legacy flat `user.role` leg. - `platformAdminCapability` (default `'accounts:manage'`) — checked via `canAsync`, which hydrates a `users.roles` RELATIONSHIP at depth 1 and treats a `super-admin` slug as an implicit `'*'` grant. Sites whose roles live in the relation (rather than the flat field) are covered by this leg with no configuration. - `platformAdminBypass: false` — opt out entirely, for deployments where operators must not read tenant data. `accountScopedRead` returns `true` rather than a `Where` for an operator, deliberately: an operator must also see rows whose account relationship is null or orphaned, which no `{account: {in: [...]}}` filter would ever match — and those are precisely the rows worth looking at when provisioning has gone wrong. No behavior change for tenant users: non-admins are scoped exactly as before.
v0.2.3patch

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: `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.
v0.2.2patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
v0.1.2patch

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.
v0.1.1patch

598611c: Fix non-atomic approval CAS (double-execution race). `approveAccountRequest` and `denyAccountRequest` claimed a pending request via `payload.update({ where: { id, status: 'pending' } })`, which the `@payloadcms/db-mongodb` adapter implements as FIND-then-`updateMany` (read-then-write) — so two concurrent approvers both read `pending` and both win, double-running the `execute` callback. The claim now uses the adapter's atomic `Model.findOneAndUpdate({ _id, status: 'pending' } -> next)` (the same primitive the adapter uses for its own job-queue claims), with a documented non-atomic bulk-update fallback for non-Mongo adapters. The test fake was upgraded to model the adapter's real (non-atomic bulk update vs. atomic findOneAndUpdate) behavior, giving the concurrent-approver test genuine teeth.

  • 598611c: Fix non-atomic approval CAS (double-execution race). `approveAccountRequest` and `denyAccountRequest` claimed a pending request via `payload.update({ where: { id, status: 'pending' } })`, which the `@payloadcms/db-mongodb` adapter implements as FIND-then-`updateMany` (read-then-write) — so two concurrent approvers both read `pending` and both win, double-running the `execute` callback. The claim now uses the adapter's atomic `Model.findOneAndUpdate({ _id, status: 'pending' } -> next)` (the same primitive the adapter uses for its own job-queue claims), with a documented non-atomic bulk-update fallback for non-Mongo adapters. The test fake was upgraded to model the adapter's real (non-atomic bulk update vs. atomic findOneAndUpdate) behavior, giving the concurrent-approver test genuine teeth.

Workflow

v0.3.4
v0.3.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.3.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.3.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.3.1patch

7830a60: `executeClaimed` no longer unwinds a claim when `execute` succeeded but the settle write failed, so a retry cannot run the action a second time. Only a throw from `execute` unwinds to `unwindStatus`. A failing settle write is retried up to three times; if it still fails, the row stays at its claimed status, the failure is logged, and the result carries `settled: false` and `settleError` alongside `success: true` and the action's `result`.

  • 7830a60: `executeClaimed` no longer unwinds a claim when `execute` succeeded but the settle write failed, so a retry cannot run the action a second time. Only a throw from `execute` unwinds to `unwindStatus`. A failing settle write is retried up to three times; if it still fails, the row stays at its claimed status, the failure is logged, and the result carries `settled: false` and `settleError` alongside `success: true` and the action's `result`.
v0.3.0minor

84942bc: D1 (Locations wave): additive edge identity and table-level access semantics on the transition table. A `WorkflowTransition` may now carry an optional `key` (an identity unique only among edges sharing the same `(from, to)` pair), an actor-class gate `by` (free-form, like `requiresRole`; `'author' | 'curator' | 'system'` are what the Locations lifecycle ports over with), and its own `recusal: RecusalPredicate[]` — so a lifecycle rule such as "a curator who is also the report's author may not approve their own work" lives IN the reviewable table rather than in imperative caller code (ADR-018). Two new lookups: `findTransitions(table, from, to)` returns EVERY edge matching a pair (plural), and `resolveTransition(table, { from, to, key?, ctx })` is the guard-aware resolver that picks the first edge whose `by`, edge-level `recusal` and own `guard` all pass. Together they make a table with two legitimate edges between the same two statuses expressible — a consumer's `in-review -> draft` is both "Withdraw" (by the author) and "Request changes" (by a curator) — where a single-match lookup would silently collapse one into the other. `findTransition` is UNCHANGED and pinned by test: it still returns the FIRST edge of a shared pair, same predicate, same return shape. `guardedTransition` gains two optional args — `key` (select one edge of a shared pair; omitted, the lookup is byte-identical to before) and `actorClasses` (checked against the resolved edge's `by`, returning the new `wrong-actor-class` outcome BEFORE the claim touches the row) — and appends the resolved edge's own `recusal` to the caller's array, so both apply and neither replaces the other. Every pre-D1 table sets none of these fields, so no existing caller's behavior changes.

  • 84942bc: D1 (Locations wave): additive edge identity and table-level access semantics on the transition table. A `WorkflowTransition` may now carry an optional `key` (an identity unique only among edges sharing the same `(from, to)` pair), an actor-class gate `by` (free-form, like `requiresRole`; `'author' | 'curator' | 'system'` are what the Locations lifecycle ports over with), and its own `recusal: RecusalPredicate[]` — so a lifecycle rule such as "a curator who is also the report's author may not approve their own work" lives IN the reviewable table rather than in imperative caller code (ADR-018). Two new lookups: `findTransitions(table, from, to)` returns EVERY edge matching a pair (plural), and `resolveTransition(table, { from, to, key?, ctx })` is the guard-aware resolver that picks the first edge whose `by`, edge-level `recusal` and own `guard` all pass. Together they make a table with two legitimate edges between the same two statuses expressible — a consumer's `in-review -> draft` is both "Withdraw" (by the author) and "Request changes" (by a curator) — where a single-match lookup would silently collapse one into the other. `findTransition` is UNCHANGED and pinned by test: it still returns the FIRST edge of a shared pair, same predicate, same return shape. `guardedTransition` gains two optional args — `key` (select one edge of a shared pair; omitted, the lookup is byte-identical to before) and `actorClasses` (checked against the resolved edge's `by`, returning the new `wrong-actor-class` outcome BEFORE the claim touches the row) — and appends the resolved edge's own `recusal` to the caller's array, so both apply and neither replaces the other. Every pre-D1 table sets none of these fields, so no existing caller's behavior changes.
v0.2.0minor

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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.

  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
  • 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.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behavior changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
v0.1.1patch

0f6fb80: Claims bypass Payload hook dispatch by design (atomicity needs the raw adapter path) — now documented in claim.ts and a new README section. Adds dispatchAfterChange/dispatchAfterDelete (./server), built on Payload's own createLocalReq and matching the internal afterChange/afterDelete invocation shapes (payload 3.81), plus an opt-in dispatchHooks flag on claimTransition that captures the atomic write's pre-image as previousDoc and fires the collection's hooks after a winning claim. Default behavior unchanged. Found by the first consumer adoption, which had hand-replayed hooks with a synthesized req.

  • 0f6fb80: Claims bypass Payload hook dispatch by design (atomicity needs the raw adapter path) — now documented in claim.ts and a new README section. Adds dispatchAfterChange/dispatchAfterDelete (./server), built on Payload's own createLocalReq and matching the internal afterChange/afterDelete invocation shapes (payload 3.81), plus an opt-in dispatchHooks flag on claimTransition that captures the atomic write's pre-image as previousDoc and fires the collection's hooks after a winning claim. Default behavior unchanged. Found by the first consumer adoption, which had hand-replayed hooks with a synthesized req.

Ledger

v0.2.4
v0.2.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.2.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.1patch

f57ad0c: Two transactions that first touch a wallet in the same new currency at the same time no longer create two balance entries for that currency. The balance-apply hook's first-touch `$push` now only matches a wallet that still has no entry for the currency, so the existence check and the push are one atomic update; a writer that loses the race retries the `$inc` instead. A leg that still cannot be applied (for example, the wallet does not exist) now throws into the existing failure path — reported, and in `strict` mode compensated and marked `failed` — instead of being dropped without a report.

  • f57ad0c: Two transactions that first touch a wallet in the same new currency at the same time no longer create two balance entries for that currency. The balance-apply hook's first-touch `$push` now only matches a wallet that still has no entry for the currency, so the existence check and the push are one atomic update; a writer that loses the race retries the `$inc` instead. A leg that still cannot be applied (for example, the wallet does not exist) now throws into the existing failure path — reported, and in `strict` mode compensated and marked `failed` — instead of being dropped without a report.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.2.0minor

485e778: **BREAKING:** `createWalletCollection()`'s default access no longer makes every wallet publicly readable. The default `read` is now owner-or-admin: anonymous callers are denied, a signed-in member sees only the wallet(s) whose `owner` is their `members` row (`members.user == req.user.id`), and `admin`/`super-admin` see all. Create/update/delete stay denied. New optional config: `adminPredicate` (pass core's admin gate when `@wabbit/tome-core` is installed — core stays an optional peer), `resolveOwners` (for units/wings/accounts-owned wallets), and `membersSlug`. A consumer that wants public wallets passes `access` explicitly, as before. New exports: `walletOwnerOrAdminRead`, `isLedgerAdmin`.

  • 485e778: **BREAKING:** `createWalletCollection()`'s default access no longer makes every wallet publicly readable. The default `read` is now owner-or-admin: anonymous callers are denied, a signed-in member sees only the wallet(s) whose `owner` is their `members` row (`members.user == req.user.id`), and `admin`/`super-admin` see all. Create/update/delete stay denied. New optional config: `adminPredicate` (pass core's admin gate when `@wabbit/tome-core` is installed — core stays an optional peer), `resolveOwners` (for units/wings/accounts-owned wallets), and `membersSlug`. A consumer that wants public wallets passes `access` explicitly, as before. New exports: `walletOwnerOrAdminRead`, `isLedgerAdmin`.
v0.1.2patch

80c99e3: Fix: the balance-apply hook's failure reporting is now delegated, not additive. When a consumer supplies `onSyncFailure`, that handler is the sole report for a given failure and `payload.logger.error` is no longer also called — previously both fired unconditionally in every mode (`strict` and `legacy-vngd`), so a consumer whose handler itself logs or persists the failure (e.g. a consumer's `reportSyncFailure`, which writes an audit row and logs) saw every leg failure, atomic-transaction rollback, compensation failure, `auditSink` throw, and `walletStoreUnreachablePolicy: 'loud'` event double-logged. With no handler supplied, behavior is unchanged: `logger.error` reports loudly, exactly as before. If the supplied handler itself throws, the factory falls back to `logger.error`, logging once with both the original failure and the handler's own error surfaced — a failing reporter must never make the failure silent.

  • 80c99e3: Fix: the balance-apply hook's failure reporting is now delegated, not additive. When a consumer supplies `onSyncFailure`, that handler is the sole report for a given failure and `payload.logger.error` is no longer also called — previously both fired unconditionally in every mode (`strict` and `legacy-vngd`), so a consumer whose handler itself logs or persists the failure (e.g. a consumer's `reportSyncFailure`, which writes an audit row and logs) saw every leg failure, atomic-transaction rollback, compensation failure, `auditSink` throw, and `walletStoreUnreachablePolicy: 'loud'` event double-logged. With no handler supplied, behavior is unchanged: `logger.error` reports loudly, exactly as before. If the supplied handler itself throws, the factory falls back to `logger.error`, logging once with both the original failure and the handler's own error surfaced — a failing reporter must never make the failure silent.
v0.1.1patch

637db74: Add the README the `files` field already promised (install, composition order, full public API, the integrity-contract matrix, compat mode, decisions) and a seeded CHANGELOG. `wabbit.family: "ledger"` is now a canonical family in `assert-license-metadata` (adopted 2026-09-01), so the licensing scope generator can place the package.

  • 637db74: Add the README the `files` field already promised (install, composition order, full public API, the integrity-contract matrix, compat mode, decisions) and a seeded CHANGELOG. `wabbit.family: "ledger"` is now a canonical family in `assert-license-metadata` (adopted 2026-09-01), so the licensing scope generator can place the package.
  • 8fc9702: Fix `resolveSelectOptions`: an `{ extend: [...] }` override now dedupes by `value`, so an option whose value already exists in a factory's defaults is skipped and the default wins. This was a real behavior divergence, not a tidy-up. Every other `resolveSelectOptions` in the platform deduped; this one appended blindly, so `createWalletCollection({ walletTypeOptions: { extend: [{ value: 'personal', … }] } })` emitted a Payload `select` carrying `personal` twice — duplicate keys in the admin dropdown and whichever label Payload happened to render. The 2026-09-01 sale-readiness audit (§5.1) flagged it as the sharp edge of "same option name, silently different behavior per layer". Covered by a regression test in `tests/wallet-collection.test.ts`. The package keeps its own copies of `mergeHooks` and `fieldShape` rather than importing `@wabbit/tome-core`, because it declares core as an OPTIONAL peer and a module-scope import would make that peer hard in practice while still advertising it as optional. Both files now say so, and `mergeHooks` is the single allowlisted entry in the new `assert:no-forked-primitives` gate, with that reason recorded there. Core's canonical `@wabbit/tome-core/fields/fieldShape` was promoted FROM ledger's copy — it was the superset, the only one carrying `applyFieldShape`.
  • 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.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behavior changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
v0.1.0minor

bc6569de: NEW package — community-currency ledger: `createCurrencyCollection`, `createWalletCollection`, `createTransactionCollection` factories with the strong integrity contract (balance floor, actor guard, idempotency key, atomic transaction when the adapter supports it, loud wallet-store failures) and `compatibilityMode: 'legacy-vngd'` as the byte-identical migration escape hatch. Server-only `getWalletBalance` on `./server`. Deliberately distinct from `@wabbit/tome-economy` (real-money Stripe commerce). Adopted 2026-09-01 as the thirteenth license family, `ledger`.

  • bc6569de: NEW package — community-currency ledger: `createCurrencyCollection`, `createWalletCollection`, `createTransactionCollection` factories with the strong integrity contract (balance floor, actor guard, idempotency key, atomic transaction when the adapter supports it, loud wallet-store failures) and `compatibilityMode: 'legacy-vngd'` as the byte-identical migration escape hatch. Server-only `getWalletBalance` on `./server`. Deliberately distinct from `@wabbit/tome-economy` (real-money Stripe commerce). Adopted 2026-09-01 as the thirteenth license family, `ledger`.

Sc

v0.5.2
v0.5.2patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
  • Updated dependencies [49eb91d] - @wabbit/tome-blocks-sc-pack@0.49.8 - @wabbit/tome-cop@0.2.5
v0.5.1patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
  • Updated dependencies [dd19c82]
  • Updated dependencies [dd19c82]
  • Updated dependencies [deea397] - @wabbit/tome-blocks-sc-pack@0.49.4 - @wabbit/tome-blocks-signal-theme@0.49.4 - @wabbit/tome-cop@0.2.5
v0.5.0minor

1d65df1: **BREAKING:** Every SC collection now reads org members only by default (`orgMemberRead` from `@wabbit/tome-org`); a signed-in account with no member row reads nothing. Before, ships, gameplay activities, locations, squadrons, task forces, task force assignments, asset availability, resource requests, registered assets and asset assignments read core's `authenticated`: any signed-in account read every row. On a site with public sign-up that handed every crew roster, task force, availability schedule and leadership-only vessel to a stranger with an account. The new default answers true for staff, for a holder of the org permissions the factory already gates writes on (`MANAGE_STRUCTURE`, the logistics keys), and for an account with a row in the member collection (`memberSlug`); false for everyone else. Registered assets are versioned, so `readVersions` now follows the same rule, and asset transfer requests' version history follows their scoped read rule, instead of Payload's signed-in default. **Migration:** nothing to do on a site where every account has a member row. A site that relies on any-signed-in reads passes `access: { read: authenticated }` on the factory (the registry factories take a full `access` object), or on the layer's per-collection `access` map; a consumer-supplied `access` wins exactly as before. A site with a stricter notion of membership passes `access: { read: orgMemberRead({ memberSlug, isMember }) }`. Requires `@wabbit/tome-org` 0.19 or later, which exports `orgMemberRead`.

  • 1d65df1: **BREAKING:** Every SC collection now reads org members only by default (`orgMemberRead` from `@wabbit/tome-org`); a signed-in account with no member row reads nothing. Before, ships, gameplay activities, locations, squadrons, task forces, task force assignments, asset availability, resource requests, registered assets and asset assignments read core's `authenticated`: any signed-in account read every row. On a site with public sign-up that handed every crew roster, task force, availability schedule and leadership-only vessel to a stranger with an account. The new default answers true for staff, for a holder of the org permissions the factory already gates writes on (`MANAGE_STRUCTURE`, the logistics keys), and for an account with a row in the member collection (`memberSlug`); false for everyone else. Registered assets are versioned, so `readVersions` now follows the same rule, and asset transfer requests' version history follows their scoped read rule, instead of Payload's signed-in default. **Migration:** nothing to do on a site where every account has a member row. A site that relies on any-signed-in reads passes `access: { read: authenticated }` on the factory (the registry factories take a full `access` object), or on the layer's per-collection `access` map; a consumer-supplied `access` wins exactly as before. A site with a stricter notion of membership passes `access: { read: orgMemberRead({ memberSlug, isMember }) }`. Requires `@wabbit/tome-org` 0.19 or later, which exports `orgMemberRead`.
  • @wabbit/tome-cop@0.2.4
v0.4.16patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
  • Updated dependencies [d51ad89]
  • Updated dependencies [d51ad89]
  • Updated dependencies [d51ad89] - @wabbit/tome-blocks-sc-pack@0.43.2 - @wabbit/tome-blocks-signal-theme@0.43.2 - @wabbit/tome-cop@0.2.4
v0.4.15patch

@wabbit/tome-blocks-sc-pack@0.37.1

  • @wabbit/tome-blocks-sc-pack@0.37.1
  • @wabbit/tome-blocks-signal-theme@0.38.0
  • @wabbit/tome-cop@0.2.3
v0.4.14patch

@wabbit/tome-blocks-sc-pack@0.28.5

  • @wabbit/tome-blocks-sc-pack@0.28.5
  • @wabbit/tome-blocks-signal-theme@0.37.0
  • @wabbit/tome-cop@0.2.2
v0.4.13patch

@wabbit/tome-blocks-sc-pack@0.28.5

  • @wabbit/tome-blocks-sc-pack@0.28.5
  • @wabbit/tome-blocks-signal-theme@0.35.0
  • @wabbit/tome-cop@0.2.2
v0.4.12patch

Updated dependencies [b8ccf85] - @wabbit/tome-blocks-signal-theme@0.31.0 - @wabbit/tome-blocks-sc-pack@0.28.5 - @wabbit/tome-cop@0.2.2

  • Updated dependencies [b8ccf85] - @wabbit/tome-blocks-signal-theme@0.31.0 - @wabbit/tome-blocks-sc-pack@0.28.5 - @wabbit/tome-cop@0.2.2
v0.4.11patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
  • Updated dependencies [775f90a] - @wabbit/tome-blocks-sc-pack@0.28.5 - @wabbit/tome-blocks-signal-theme@0.28.5 - @wabbit/tome-cop@0.2.2
v0.4.10patch

Updated dependencies [d08fc38]

  • Updated dependencies [d08fc38]
  • Updated dependencies [58655f4] - @wabbit/tome-blocks-signal-theme@0.28.0 - @wabbit/tome-blocks-sc-pack@0.19.0
v0.4.9patch

Updated dependencies [c14a133] - @wabbit/tome-blocks-signal-theme@0.27.0 - @wabbit/tome-blocks-sc-pack@0.19.0 - @wabbit/tome-cop@0.2.1

  • Updated dependencies [c14a133] - @wabbit/tome-blocks-signal-theme@0.27.0 - @wabbit/tome-blocks-sc-pack@0.19.0 - @wabbit/tome-cop@0.2.1
v0.4.8patch

@wabbit/tome-blocks-sc-pack@0.19.0

  • @wabbit/tome-blocks-sc-pack@0.19.0
  • @wabbit/tome-blocks-signal-theme@0.26.0
  • @wabbit/tome-cop@0.2.1
v0.4.7patch

Updated dependencies - @wabbit/tome-blocks-signal-theme@0.23.0

  • Updated dependencies - @wabbit/tome-blocks-signal-theme@0.23.0
v0.4.6patch

Updated dependencies [d5d72e1]

  • Updated dependencies [d5d72e1]
  • Updated dependencies [9ac8d3d] - @wabbit/tome-cop@0.2.0 - @wabbit/tome-blocks-signal-theme@0.20.0 - @wabbit/tome-blocks-sc-pack@0.19.0
v0.4.5patch

Updated dependencies [f6cfffa]

  • Updated dependencies [f6cfffa]
  • Updated dependencies [89a8338] - @wabbit/tome-blocks-sc-pack@0.19.0
v0.4.4patch

fcf6d33: `createSCPlatform` now returns the resolved `blocks` flags so bundled-pack opt-outs can take effect. `createSCPlatform` now returns the resolved `blocks` flags (`{ scPack, signalTheme }`, both default `true`) alongside `collections`, and exports the `SCBlockPacks` type. The flags were previously accepted and never read anywhere, so opting out of a bundled pack did nothing; read `sc.blocks` in your block-registration code to honor them. Additive — `sc.collections` is unchanged.

  • fcf6d33: `createSCPlatform` now returns the resolved `blocks` flags so bundled-pack opt-outs can take effect. `createSCPlatform` now returns the resolved `blocks` flags (`{ scPack, signalTheme }`, both default `true`) alongside `collections`, and exports the `SCBlockPacks` type. The flags were previously accepted and never read anywhere, so opting out of a bundled pack did nothing; read `sc.blocks` in your block-registration code to honor them. Additive — `sc.collections` is unchanged.
  • 3ca8002: Task-force member counts are recalculated with `payload.count()` instead of reading every active assignment. No API or behavior change. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
  • 0bd7c3f: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
  • Updated dependencies [89161af]
  • Updated dependencies [0aa80a3] - @wabbit/tome-blocks-signal-theme@0.18.1 - @wabbit/tome-cop@0.1.9 - @wabbit/tome-blocks-sc-pack@0.18.0
v0.4.3patch

67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`.

  • 67eb3dc: Local relationship-id helpers are replaced by `@wabbit/tome-core/utilities/relationId`. Each call site maps to the core reader with the same return shape (raw vs. stringified id, `null` vs. `undefined`, polymorphic), so behavior is unchanged. The `@wabbit/tome-core` peer floor goes up to `>=1.17.0` because that is the first core version exporting `relationIdRaw`, `relationIds` and `relationIdsRaw`.
  • Updated dependencies [c3468b0]
  • Updated dependencies [c3468b0] - @wabbit/tome-blocks-sc-pack@0.18.0 - @wabbit/tome-blocks-signal-theme@0.18.0 - @wabbit/tome-cop@0.1.8
v0.4.2patch

Updated dependencies [404d325] - @wabbit/tome-blocks-signal-theme@0.17.0 - @wabbit/tome-blocks-sc-pack@0.17.0 - @wabbit/tome-cop@0.1.8

  • Updated dependencies [404d325] - @wabbit/tome-blocks-signal-theme@0.17.0 - @wabbit/tome-blocks-sc-pack@0.17.0 - @wabbit/tome-cop@0.1.8
v0.4.1patch

ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.

  • ce3d12d: Adopt `@wabbit/tome-core/fields/address` and `@wabbit/tome-core/utilities/relationId` at the sites the audit counted (2026-09-01 sale-readiness audit §5.1, T3(g)). **No stored field name, and no emitted field array, changes anywhere in this changeset** — each adopter passes the vocabulary it already stores, and each ships a characterization test that was written from the pre-change source, run green against the untouched factory, and run green again after. **Address group — five sites, one implementation.** - `@wabbit/tome-crm` — `accounts` and `contacts` each carried a byte-identical seven-field `address` group. Both now spread `postalAddressFields({ vocabulary: 'legacy-crm' })` after their own `name` line (`name` is the company/contact line, not a postal line). `tests/address-characterisation.test.ts` pins both groups whole. - `@wabbit/tome-deals` — `billingAddress` and `shippingAddress` inside the frozen Customer Snapshot were copies three and four. They now come from one `buildSnapshotAddressGroup` helper: `name` + `company` prepended locally, the six postal lines from core, and the eight per-field labels plus the `'US'` country default passed through core's `fieldOverrides` seam. The snapshot is a legal-offer record frozen after send, so a field-name change would orphan the address on every deal already sent; `tests/address-characterisation.test.ts` pins both groups and the fact that they differ only in the group label and the recipient line's label. - `@wabbit/tome-fulfillment` — the fifth copy, and the only one that validated `country`. Its postal lines stay FLAT at collection top level (they are stored columns with PII rows and a GDPR registration behind them), now via `postalAddressFields({ vocabulary: 'postal', required: true, validateCountry: true })`. The ISO-3166 validator and its uppercase-normalizing hook moved into core verbatim; because a moved function is a new object, `tests/address-characterisation.test.ts` pins the whole top-level field ORDER plus the validator's and hook's BEHAVIOR (accepts `US`, rejects `usa`, rewrites `' us '` to `'US'`), not their identity. **`relationId` — the four-return-types problem.** - `@wabbit/tome-lms` — twelve modules under `src/server` (`academy`, `catalog`, `certificates`, `course`, `dashboard`, `enrollment`, `grades`, `leaderboard`, `learnerShell`, `notes`, `profile`, `reviews`) carried a byte-identical `string | null` copy. They import `relationId` from core now. One behavioral difference, strictly an improvement: on a malformed populated doc (`{ id: null }`, `{ id: {} }`) the old copy returned the STRING `'null'` / `'[object Object]'` as an id; core returns `null`. `tests/relation-id-adoption.test.ts` pins the adoption itself, because adoption is the thing that decays — the July 2026 audit's finding, repeated verbatim in September, was "extraction keeps happening, adoption does not." **Not migrated, deliberately:** `src/guards`, `src/utilities/{grading,prerequisites,progress}.ts`, `src/hooks/**`, `src/server/mutations/helpers.ts` and `src/server/awardGate.ts` return `string | number` or `undefined`. Migrating those is a semantic change, not an import change, and belongs in a pass that owns their call sites. The new test names them as out of scope so the next reader does not have to re-derive why. - `@wabbit/tome-sc` — the registry sub-cluster's copy is gone; `collections/registry/shared.ts` re-exports core's `relationId`, keeping `extractId` as a local alias (the module is private to that sub-cluster). **This one WIDENS:** the sc copy returned `string | number`, so a populated doc's numeric id came through unstringified. It is now stringified, which makes `===` between two resolved ids agree — the behavior every call site in the cluster already assumed. Ids handed back to `payload.find`/`update` are unaffected, since Payload accepts either form in a `where` clause. sc's 179 tests stay green. - `@wabbit/tome-crm` — the inline ternary in `integration/deals.ts` (`typeof oppRaw === 'object' ? oppRaw.id : oppRaw`) was the fifth shape and had the same numeric-id asymmetry; it is one `relationId(deal.opportunity)` call now. **`fetchMemberId` ×4 — one implementation (sc).** `asset-availability`, `fleet-logs` and `fleet` each carried a verbatim copy of the auth-user → Member-row lookup, and `resource-requests` carried its projecting twin. All four now import from `src/access/fetchMemberId.ts`, which documents why each query knob is load-bearing: `overrideAccess: true` (the member collection's own read access may itself depend on membership, so without the bypass this is a circular check that denies the owner their own row), `depth: 0`, `pagination: false`. The id is returned in its STORED type here rather than through `relationId` — this is an identity read fed straight back into a `where` clause, not a relationship read. `tests/fleet-shared-helpers.test.ts` pins the adoption, the three knobs, and the null-for-anonymous contract.
  • 8fc9702: Import `mergeHooks`, `fieldShape` and the select-option override contract from `@wabbit/tome-core` instead of keeping local copies (2026-09-01 sale-readiness audit §5.1, T3(a)). No API change: every symbol these packages exported before is still exported, now re-exported from core, and every factory produces byte-identical output. Deleted, with every call site repointed: - `org/src/hooks/mergeHooks.ts`, `lms/src/collections/shared/mergeHooks.ts`, `sc/src/extensions/mergeHooks.ts` → `@wabbit/tome-core/hooks/mergeHooks`. Core has exported this since July; sc's copy still carried a header claiming "Neither @wabbit/tome-core nor @wabbit/tome-org exports this." - `lms/src/server/jobs/paginate.ts`, `crowdfund/src/server/jobs/paginate.ts`, `workflow/src/server/paginate.ts` → `@wabbit/tome-core/jobs`. - `org/src/fieldShape.ts` + `org/src/insertFieldsAfter.ts`, `lms/src/collections/shared/fieldShape.ts` → `@wabbit/tome-core/fields/fieldShape`. Org's `resolveFieldDescription` / `FieldDescriptionOverride` were NOT part of the duplicated set and stay in the package, moved to `org/src/fieldDescriptions.ts`. - `org/src/optionOverrides.ts`, `lms/src/collections/shared/optionOverrides.ts` → `@wabbit/tome-core/fields/selectOptions`; `sc/src/collections/registry/shared.ts` now re-exports it (its `extractId` is a separate audit item and is untouched). **Peer floor raised to `@wabbit/tome-core` `>=1.14.0 <2.0.0`** in all five packages, because each now imports a subpath or a named export that first exists in that core minor: `./fields/fieldShape` and `./fields/selectOptions` are new subpaths, and `findPaged`/`chunk`/`readPositiveNumber` are new named exports on the pre-existing `./jobs`. Crowdfund's floor moves from `>=1.7.0` even though `./jobs` itself shipped in 1.7.0 — the subpath resolving is not the same thing as the export existing, which is the sharper version of the lesson its own 0.1.1 CHANGELOG records (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Org moves from `>=1.2.0`, lms from `>=1.0.0`, sc from `>=1.11.0`, workflow from `>=1.7.0`. Header comments that pointed at the deleted files, or asserted core did not export these, were corrected rather than left dangling.
  • 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 honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `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.
  • 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.
  • 04309f5: Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks. No behavior changes — every try/catch, failure reporter and `overrideAccess` justification is preserved; only the number of round-trips changes. **`find({ limit: 0 })` → `payload.count()`** — `limit: 0` sets `pagination: false` in the Mongo adapter, so the query loads every matching row into memory to produce one number. `@wabbit/tome-org` documents this as a production incident in `hooks/attendance-count-sync.ts:6-11` and had reintroduced it in `collections/recruiting/JoinRequest.ts`'s one-pending-request guard; `@wabbit/tome-sc`'s squadron member recount had the same shape. **Independent lookups → `Promise.all` / `Promise.allSettled`** — org's `createGiverWindowAccess` (a per-request access check that took two serial round-trips), `EventAttendance`'s display-name composer and `AwardPresentation`'s, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. `allSettled` wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown `NegativeBalanceError` still names the same wallet the serial version did. **Independent per-row writes → `batchWrite`** (`@wabbit/tome-core/utilities/batch`) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic `memberCount` fallback; lms's certification-expiry sweep (now paced in `WRITE_CHUNK` chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile. **Same `data` for every row → one bulk `payload.update({ where, data })`** — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching `sc/src/gdpr.ts`'s `makeNullRefHandler`. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments. **Two collection-level fixes.** sc's Fleet had two field-level `beforeChange` hooks each issuing a `findByID` for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both `chassisName` and `name`. lms's `checkCertificationExpiry` re-derived `recountHolders` once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right. `@wabbit/tome-crowdfund`'s `settleCampaign` pledge loop is untouched and now carries an explicit `eslint-disable` plus the reason: it captures money one pledge at a time against a `maxCapturesPerRun` budget that only bounds anything if the iterations are serialized. `@wabbit/tome-org`, `@wabbit/tome-lms`, `@wabbit/tome-workflow` and `@wabbit/tome-crowdfund` raise their `@wabbit/tome-core` peer floor to `>=1.14.0`, the release that adds `./utilities/batch`. org and lms were also understating their floor before this change — both already imported `@wabbit/tome-core/jobs`, added in core 1.7.0, while declaring `>=1.2.0` / `>=1.0.0`.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • b01ca1f: `createSCLayer` now registers `@wabbit/tome-sc` with the platform layer registry. Through 0.4.0 tome-sc exported a full layer factory — twelve collections, a permission namespace, GDPR registration — and never called `registerLayer`. `hasLayer('@wabbit/tome-sc')` was therefore permanently `false` for every consumer, and any cross-layer feature gate keyed on SC's presence could never fire. Nothing surfaced it, because a missing registration is indistinguishable from a layer that is genuinely not installed. The registration follows the sibling pattern exactly: a static import of `@wabbit/tome-core/utilities/layerRegistry` (core is a required peer here, and the registry is `globalThis`-backed, so a static import shares one store across ESM/CJS/bundler/Payload-CLI contexts — never a lazy `require()`), wrapped in `try/catch` to swallow only the duplicate-registration throw under HMR or a `createSCLayer` call followed by `createSCPlatform`. The manifest is real, not a stub: `version` reads the new `SC_LAYER_VERSION` leaf const, `collections` is the twelve resolved slugs, and the `adminNav` entry uses `config.adminGroup ?? 'Organization'` — the same label the twelve collection factories resolve — under `domain: 'people'` at `order: 10`. Both halves matter: the sidebar resolver pairs manifest entries to collections by that group string, and its "first manifest wins for a given group label" rule means SC's domain must agree with org's registration for the same label, or the rendered domain would depend on config-build order. Additive: a consumer that never read the registry is unaffected; one that did now sees SC where it previously saw nothing.
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-sc-pack@0.16.0 - @wabbit/tome-blocks-signal-theme@0.16.0 - @wabbit/tome-cop@0.1.8
v0.4.0minor

3e4af2b: Add the `registered-assets` sub-cluster (Wave 2R / 2R-I6): `createRegisteredAssetCollection`, `createAssetAssignmentCollection`, `createAssetTransferRequestCollection`, and `registerRegisteredAssetsGdpr`, ported from the reference consumer's `CommissionedShips`/`ShipAssignments`/`ShipTransferRequests` cluster under the neutral name adopted for this package (Wave 2 residue extraction plan §1c/§5a). NOT part of `createSCLayer`/`createSCPlatform`'s fixed collection tuple — same opt-in posture `shipsCollection`/`certificationsSlug` held for years before this increment; a consumer composes the three factories explicitly. - `createRegisteredAssetCollection`: every stored field name overridable via `fieldNames` (the reference consumer keeps `shipClass`/`currentAsset`/`affiliatedUnit`/`affiliatedWing`/`bio` — zero data migration); `linkedAsset` (reference consumer: `currentAsset`) emitted only when `linkedAssetSlug` is configured; `canManage` replaces the reference consumer's documented `authenticated` placeholder, defaulting to the new `MANAGE_REGISTERED_ASSETS` permission; absorbs `createParentScopeMirrorHook` (reference consumer: `mirrorAffiliatedWingFromUnit`) and `createAssignmentAutoCloseHook` (reference consumer: `autoCloseAssignmentsOnDecommission`, terminal statuses configurable); `statusOptions` defaults to `active|decommissioned|retired` — the reference consumer replaces wholesale with its three including `lost-in-action`. - `createAssetAssignmentCollection`: absorbs `computeActiveFlag` verbatim. Models the reference consumer's Option C design decision (a ship officer billet's holder-of-record lives on the assignment, not on `@wabbit/tome-org`'s Position `currentHolder`) as `participatesInPositionCascade` (default `false`). The `true` opt-in wires `createOfficerPositionSyncHook`, whose doc comment documents the concurrent-write hazard re-arming the cascade introduces — no cross-collection concurrency guard exists, so a raced write from org's own Position flow can silently disappear. - `createAssetTransferRequestCollection`: generalizes the reference consumer's `maybeApplyTransfer` into `createTransferApplyHook`, hand-rolling the same settle-on-success/unwind-on-throw-with-error-field pattern the reference consumer's real `tome-integration` branch delegates to `@wabbit/tome-workflow/server`'s `executeClaimed` (Wave 4 I2) — `lastExecutionError` itself already exists verbatim in that branch's schema, not new. The wing-scoped access matrix (`decideReadTransferRequest`/`decideUpdateTransferRequest`) is `Where`-returning throughout (`@wabbit/tome-core/access/scoped`'s `andWhere`/`orWhere`) with an injectable `resolveLeadership` seam, unaffected by the above — `access.ts` is byte-identical between the reference consumer's main branch and `tome-integration`. Exports `assetTransferTopology`, structurally identical to the reference consumer's real `shipTransferRequestsWorkflowTopology` (`ShipTransferRequests/workflow.ts`, `tome-integration` branch) — `{ kind: 'single', approverPoolResolver }`, no escalation/recusal used there either. `@wabbit/tome-workflow` is added as an OPTIONAL peer, referenced only via `import type`: `packages/sc` itself has no prior runtime relationship to it, even though the reference consumer's real cluster does — not every `registered-assets` consumer should be forced into that runtime dependency for one collection factory. - `MANAGE_REGISTERED_ASSETS` added to `SC_PERMISSIONS`. - `registerRegisteredAssetsGdpr`/`unregisterRegisteredAssetsGdpr` (sibling to `registerScGdpr`, matching `@wabbit/tome-core/field-reports`'s `registerFieldReportsGdpr` precedent — not auto-wired): `registered-assets.createdBy` → retain; `asset-assignments.member` → hard-delete; `asset-transfer-requests` → redact (row retained, `rationale`/`decisionNotes`/`lastExecutionError` cleared). Each collection's registration is independently skippable for a consumer with its own redact-engine ownership.

  • 3e4af2b: Add the `registered-assets` sub-cluster (Wave 2R / 2R-I6): `createRegisteredAssetCollection`, `createAssetAssignmentCollection`, `createAssetTransferRequestCollection`, and `registerRegisteredAssetsGdpr`, ported from the reference consumer's `CommissionedShips`/`ShipAssignments`/`ShipTransferRequests` cluster under the neutral name adopted for this package (Wave 2 residue extraction plan §1c/§5a). NOT part of `createSCLayer`/`createSCPlatform`'s fixed collection tuple — same opt-in posture `shipsCollection`/`certificationsSlug` held for years before this increment; a consumer composes the three factories explicitly. - `createRegisteredAssetCollection`: every stored field name overridable via `fieldNames` (the reference consumer keeps `shipClass`/`currentAsset`/`affiliatedUnit`/`affiliatedWing`/`bio` — zero data migration); `linkedAsset` (reference consumer: `currentAsset`) emitted only when `linkedAssetSlug` is configured; `canManage` replaces the reference consumer's documented `authenticated` placeholder, defaulting to the new `MANAGE_REGISTERED_ASSETS` permission; absorbs `createParentScopeMirrorHook` (reference consumer: `mirrorAffiliatedWingFromUnit`) and `createAssignmentAutoCloseHook` (reference consumer: `autoCloseAssignmentsOnDecommission`, terminal statuses configurable); `statusOptions` defaults to `active|decommissioned|retired` — the reference consumer replaces wholesale with its three including `lost-in-action`. - `createAssetAssignmentCollection`: absorbs `computeActiveFlag` verbatim. Models the reference consumer's Option C design decision (a ship officer billet's holder-of-record lives on the assignment, not on `@wabbit/tome-org`'s Position `currentHolder`) as `participatesInPositionCascade` (default `false`). The `true` opt-in wires `createOfficerPositionSyncHook`, whose doc comment documents the concurrent-write hazard re-arming the cascade introduces — no cross-collection concurrency guard exists, so a raced write from org's own Position flow can silently disappear. - `createAssetTransferRequestCollection`: generalizes the reference consumer's `maybeApplyTransfer` into `createTransferApplyHook`, hand-rolling the same settle-on-success/unwind-on-throw-with-error-field pattern the reference consumer's real `tome-integration` branch delegates to `@wabbit/tome-workflow/server`'s `executeClaimed` (Wave 4 I2) — `lastExecutionError` itself already exists verbatim in that branch's schema, not new. The wing-scoped access matrix (`decideReadTransferRequest`/`decideUpdateTransferRequest`) is `Where`-returning throughout (`@wabbit/tome-core/access/scoped`'s `andWhere`/`orWhere`) with an injectable `resolveLeadership` seam, unaffected by the above — `access.ts` is byte-identical between the reference consumer's main branch and `tome-integration`. Exports `assetTransferTopology`, structurally identical to the reference consumer's real `shipTransferRequestsWorkflowTopology` (`ShipTransferRequests/workflow.ts`, `tome-integration` branch) — `{ kind: 'single', approverPoolResolver }`, no escalation/recusal used there either. `@wabbit/tome-workflow` is added as an OPTIONAL peer, referenced only via `import type`: `packages/sc` itself has no prior runtime relationship to it, even though the reference consumer's real cluster does — not every `registered-assets` consumer should be forced into that runtime dependency for one collection factory. - `MANAGE_REGISTERED_ASSETS` added to `SC_PERMISSIONS`. - `registerRegisteredAssetsGdpr`/`unregisterRegisteredAssetsGdpr` (sibling to `registerScGdpr`, matching `@wabbit/tome-core/field-reports`'s `registerFieldReportsGdpr` precedent — not auto-wired): `registered-assets.createdBy` → retain; `asset-assignments.member` → hard-delete; `asset-transfer-requests` → redact (row retained, `rationale`/`decisionNotes`/`lastExecutionError` cleared). Each collection's registration is independently skippable for a consumer with its own redact-engine ownership.
v0.3.1patch

e97db45: Fix a module cycle in `@wabbit/tome-sc/rsi` between `client.ts` and `adapter.ts` (Wave 2R / I2.1 hotfix). `adapter.ts` imported `ADAPTER_VERSION` from `client.ts` to build its top-level `DEFAULT_USER_AGENT`, while `client.ts` imports `adapter.ts` for `createRSIProfileAdapter` — a live cycle, not just a type-only one, so it broke depending on which side of the cycle a consumer's module graph reached first: entering via `client.js`/`index.js` (as Payload's `generate:types` does when it resolves `RSIClient`) hit `adapter.js`'s top-level `ADAPTER_VERSION` read before `client.js` had reached its own assignment, throwing `ReferenceError: Cannot access 'ADAPTER_VERSION' before initialization`. `ADAPTER_VERSION` now lives in a new dependency-free `src/rsi/version.ts`; both `adapter.ts` and `client.ts` import it from there instead of from each other, eliminating the cycle. `client.ts` still re-exports `ADAPTER_VERSION` for backward compatibility — no import site needs to change. Adds `tests/rsi/rsiModuleLoadability.test.ts`, a regression guard that spawns a fresh `node` process against every built file in `dist/rsi/*` (both the ESM and CJS twin) plus every `./rsi*` entry in the package's `exports` map, and asserts a clean load — closing the gap that let this ship: `scripts/assert-node-loadable.mjs` only walks exports-map targets (never `adapter.js` directly, since there's no standalone subpath for it) and its `classify()` treats a thrown `ReferenceError` as a SKIP ("runtime side effect"), not a FAIL, so it stayed green through this defect on both counts.

  • e97db45: Fix a module cycle in `@wabbit/tome-sc/rsi` between `client.ts` and `adapter.ts` (Wave 2R / I2.1 hotfix). `adapter.ts` imported `ADAPTER_VERSION` from `client.ts` to build its top-level `DEFAULT_USER_AGENT`, while `client.ts` imports `adapter.ts` for `createRSIProfileAdapter` — a live cycle, not just a type-only one, so it broke depending on which side of the cycle a consumer's module graph reached first: entering via `client.js`/`index.js` (as Payload's `generate:types` does when it resolves `RSIClient`) hit `adapter.js`'s top-level `ADAPTER_VERSION` read before `client.js` had reached its own assignment, throwing `ReferenceError: Cannot access 'ADAPTER_VERSION' before initialization`. `ADAPTER_VERSION` now lives in a new dependency-free `src/rsi/version.ts`; both `adapter.ts` and `client.ts` import it from there instead of from each other, eliminating the cycle. `client.ts` still re-exports `ADAPTER_VERSION` for backward compatibility — no import site needs to change. Adds `tests/rsi/rsiModuleLoadability.test.ts`, a regression guard that spawns a fresh `node` process against every built file in `dist/rsi/*` (both the ESM and CJS twin) plus every `./rsi*` entry in the package's `exports` map, and asserts a clean load — closing the gap that let this ship: `scripts/assert-node-loadable.mjs` only walks exports-map targets (never `adapter.js` directly, since there's no standalone subpath for it) and its `classify()` treats a thrown `ReferenceError` as a SKIP ("runtime side effect"), not a FAIL, so it stayed green through this defect on both counts.
v0.3.0minor

cf2802b: Add `RSIProfileAdapter` (`createRSIProfileAdapter`, `@wabbit/tome-sc/rsi`) — the concrete `ExternalProfileAdapter<RSIProfile>` implementation for `@wabbit/tome-core/verification`'s neutral verification/cache/reconcile primitives (Wave 2R I2). This is now the ONE place RSI's markup regexes live, ported field-for-field from the reference consumer's `scrapeRsiDossier.ts`/`parseRsiHandle.ts` and characterized against the same fixture HTML (Wave 2R I0). `RSIClient.validateHandle` is rewired to use the adapter internally (same `{valid, orgTag?}` return shape) so it now benefits from retry/backoff and an optional distributed rate limiter — previously a single unretried fetch. Deliberate divergences from the reference consumer, all test-covered: `exists()`/`checkExistence()` fail CLOSED on a network error (the reference consumer fails open); a 404 resolves to `status: 'not_found'` on the first attempt with no wasted retries (the reference consumer retries a stable 404 like a transient failure and then mislabels it); no `E2E_TEST_` production bypass moved upstream; new structural `visibility` detection (`'public' | 'hidden' | 'redacted'`) that the reference consumer's scraper never had. Bumps this package's `@wabbit/tome-core` peer floor to `>=1.11.0 <2.0.0` (the `./verification` subpath this adapter is built on).

  • cf2802b: Add `RSIProfileAdapter` (`createRSIProfileAdapter`, `@wabbit/tome-sc/rsi`) — the concrete `ExternalProfileAdapter<RSIProfile>` implementation for `@wabbit/tome-core/verification`'s neutral verification/cache/reconcile primitives (Wave 2R I2). This is now the ONE place RSI's markup regexes live, ported field-for-field from the reference consumer's `scrapeRsiDossier.ts`/`parseRsiHandle.ts` and characterized against the same fixture HTML (Wave 2R I0). `RSIClient.validateHandle` is rewired to use the adapter internally (same `{valid, orgTag?}` return shape) so it now benefits from retry/backoff and an optional distributed rate limiter — previously a single unretried fetch. Deliberate divergences from the reference consumer, all test-covered: `exists()`/`checkExistence()` fail CLOSED on a network error (the reference consumer fails open); a 404 resolves to `status: 'not_found'` on the first attempt with no wasted retries (the reference consumer retries a stable 404 like a transient failure and then mislabels it); no `E2E_TEST_` production bypass moved upstream; new structural `visibility` detection (`'public' | 'hidden' | 'redacted'`) that the reference consumer's scraper never had. Bumps this package's `@wabbit/tome-core` peer floor to `>=1.11.0 <2.0.0` (the `./verification` subpath this adapter is built on).
  • @wabbit/tome-cop@0.1.7
v0.2.4patch

5c4f840: Registers this package's member-referencing collections with core's GDPR export/delete pipelines from inside the package (Wave 5 / W5-I3), mirroring `@wabbit/tome-fulfillment`'s `registerFulfillmentGdpr`. New `registerScGdpr(options?)`/`unregisterScGdpr(options?)` in `./gdpr.ts`, re-exported from the package barrel; `createSCLayer` (and therefore `createSCPlatform`) calls `registerScGdpr` automatically unless `config.registerGdpr: false`. Ten collections register, all `post-identity` (none hold subject handle text). Fleet/AssetAvailability/TaskForceAssignments (personal asset ownership) default to `retain` — this package's real consumer soft-anonymises the Member document on erasure rather than deleting it, so these FKs stay valid and resolve to "Former Member" with nothing to clear; `retain` is the correct disposition for that consumer, not a fallback. A `personalAssetMode: 'null-ref'` opt-in is available for a consumer that instead hard-deletes Members: it clears the owner field via `payload.update` (Local API, `overrideAccess: true`, full validation and hooks run) rather than any database-adapter bypass. Because those three fields are `required: true` by default, choosing `'null-ref'` also requires `memberFieldRequired: false` (new option on `SCLayerConfig`/`SCPlatformConfig`, forwarded to the three factories AND to `registerScGdpr` from the same config so the two can't drift) — `registerScGdpr` throws loudly at registration time if `'null-ref'` is requested while the fields are still required, rather than registering a mode that would fail validation on every row. FleetLogs registers `retain` (immutable service-history log). Squadrons/TaskForces/ResourceRequests/Locations/OperationTemplates/ForceTemplates register `retain` too — none of these six is named in the Wave 5 compliance plan's disposition table; registered under the plan's own stated fallback rule rather than left unregistered, flagged in `gdpr.ts`'s header for a follow-up ratification pass. Bumps the `@wabbit/tome-core` peer/dev floor to `>=1.8.0` for the `mode`/`phase`/`onNullRef` registry contract this depends on. - @wabbit/tome-cop@0.1.7

  • 5c4f840: Registers this package's member-referencing collections with core's GDPR export/delete pipelines from inside the package (Wave 5 / W5-I3), mirroring `@wabbit/tome-fulfillment`'s `registerFulfillmentGdpr`. New `registerScGdpr(options?)`/`unregisterScGdpr(options?)` in `./gdpr.ts`, re-exported from the package barrel; `createSCLayer` (and therefore `createSCPlatform`) calls `registerScGdpr` automatically unless `config.registerGdpr: false`. Ten collections register, all `post-identity` (none hold subject handle text). Fleet/AssetAvailability/TaskForceAssignments (personal asset ownership) default to `retain` — this package's real consumer soft-anonymises the Member document on erasure rather than deleting it, so these FKs stay valid and resolve to "Former Member" with nothing to clear; `retain` is the correct disposition for that consumer, not a fallback. A `personalAssetMode: 'null-ref'` opt-in is available for a consumer that instead hard-deletes Members: it clears the owner field via `payload.update` (Local API, `overrideAccess: true`, full validation and hooks run) rather than any database-adapter bypass. Because those three fields are `required: true` by default, choosing `'null-ref'` also requires `memberFieldRequired: false` (new option on `SCLayerConfig`/`SCPlatformConfig`, forwarded to the three factories AND to `registerScGdpr` from the same config so the two can't drift) — `registerScGdpr` throws loudly at registration time if `'null-ref'` is requested while the fields are still required, rather than registering a mode that would fail validation on every row. FleetLogs registers `retain` (immutable service-history log). Squadrons/TaskForces/ResourceRequests/Locations/OperationTemplates/ForceTemplates register `retain` too — none of these six is named in the Wave 5 compliance plan's disposition table; registered under the plan's own stated fallback rule rather than left unregistered, flagged in `gdpr.ts`'s header for a follow-up ratification pass. Bumps the `@wabbit/tome-core` peer/dev floor to `>=1.8.0` for the `mode`/`phase`/`onNullRef` registry contract this depends on. - @wabbit/tome-cop@0.1.7
v0.2.3patch

4e59529: Two upstream corrections surfaced by the first consumer's line-read cutover audits. **`membershipsSlug` added to `SCLayerConfig`.** The Squadron factory hardcoded its `memberships` join target to org's `'org-memberships'` — the only cross-layer slug in the package without a config knob — forcing the first consumer to patch the emitted field post-hoc. A join pointing at an unregistered collection does not error; it renders empty. **Resource-requests control flow corrected to match upstream.** The 2026-08-15 restoration had the permission keys right and the ordering wrong: logistics authority was granted before the id/status guards (officers could edit `fulfilled`/`cancelled` requests and perform id-less bulk ops) and `delete` gained a logistics branch upstream never shipped. Corrected: update = super-role → require id → block `fulfilled`/`cancelled` → requester-own → logistics-triple; delete = super-role → require id → block fulfilled → requester-own only. The integrity hook also gains upstream's priority-400 (logistics-exempt) — the field-access-only approach silently stripped the change with a 200, breaking consumers that key error handling off the thrown message. Five new tests pin the ordering.

  • 4e59529: Two upstream corrections surfaced by the first consumer's line-read cutover audits. **`membershipsSlug` added to `SCLayerConfig`.** The Squadron factory hardcoded its `memberships` join target to org's `'org-memberships'` — the only cross-layer slug in the package without a config knob — forcing the first consumer to patch the emitted field post-hoc. A join pointing at an unregistered collection does not error; it renders empty. **Resource-requests control flow corrected to match upstream.** The 2026-08-15 restoration had the permission keys right and the ordering wrong: logistics authority was granted before the id/status guards (officers could edit `fulfilled`/`cancelled` requests and perform id-less bulk ops) and `delete` gained a logistics branch upstream never shipped. Corrected: update = super-role → require id → block `fulfilled`/`cancelled` → requester-own → logistics-triple; delete = super-role → require id → block fulfilled → requester-own only. The integrity hook also gains upstream's priority-400 (logistics-exempt) — the field-access-only approach silently stripped the change with a 200, breaking consumers that key error handling off the thrown message. Five new tests pin the ordering.
v0.2.2patch

7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. - @wabbit/tome-cop@0.1.7

  • 7b66dcd: `dist` is now loadable by raw Node. tsup builds with `bundle: false`, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (`from "./hierarchy"`, `require("./hierarchy")`). Bundlers and tsx resolve those; raw Node does not. ESM raised `ERR_MODULE_NOT_FOUND`, and CJS was worse: `require("./x")` resolved to the ESM `.js` twin (`.cjs` is not in Node's CJS extension search list), and Node 22+ `require(esm)` then died on _that_ file's own extensionless import. Any consumer outside a bundler — the payload CLI under plain node, `generate:types`, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (`scripts/fix-dist-extensions.mjs --strict`) now appends explicit extensions (`.js` / `/index.js`, `.cjs` / `/index.cjs`) and fails the build on any specifier it cannot resolve rather than guessing. No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. - @wabbit/tome-cop@0.1.7
v0.2.1patch

dbac581: Import `authenticated`/`anyone` from the `@wabbit/tome-core/auth/guards` leaf instead of the `/auth` barrel. The barrel statically re-exports `createBetterAuth`, which drags `@delmaredigital/payload-better-auth` into the module graph — so any consumer loading this package outside a bundler (payload CLI under tsx: `generate:types`, seeds, migrations) crashed with ERR_MODULE_NOT_FOUND unless it installed an auth stack it may deliberately not use. Leaf-import discipline is what this platform's own consumer docs mandate; the layers now follow it themselves. No behavioral change — same functions, same leaf they always resolved to.

  • dbac581: Import `authenticated`/`anyone` from the `@wabbit/tome-core/auth/guards` leaf instead of the `/auth` barrel. The barrel statically re-exports `createBetterAuth`, which drags `@delmaredigital/payload-better-auth` into the module graph — so any consumer loading this package outside a bundler (payload CLI under tsx: `generate:types`, seeds, migrations) crashed with ERR_MODULE_NOT_FOUND unless it installed an auth stack it may deliberately not use. Leaf-import discipline is what this platform's own consumer docs mandate; the layers now follow it themselves. No behavioral change — same functions, same leaf they always resolved to.
  • 19ba623: Retire the local `authorizedCronRequest` copy in favor of `@wabbit/tome-core/jobs`. The copy's own comment explained the duplication — the function "is not exported from `@wabbit/tome-core`'s public surface" — which stopped being true in core 1.7.0. This was the last of three copies; the two in core were retired alongside the export. All three shared a timing leak: an early `authHeader.length !== expected.length` return, which is precisely the flaw `utilities/timingSafeEqual` was promoted into core to eliminate. The shared implementation hashes both inputs to a fixed-length digest before comparing, so no length branch remains to short-circuit on and a wrong-length header costs the same work as a right-length one. It also rejects a blank secret outright, so a misconfigured `CRON_SECRET` cannot authorize a blank header. Behavior is otherwise unchanged: same 500 on unconfigured secret, same 401 on a bad or missing header.
v0.2.0minor

196d642: Wave 2 absorb — prepares the layer for its first consumer. Each item restores an upstream production-tested behavior this layer had diverged from. - **Consumer hooks seam.** `SCLayerConfig` gains `hooks?:`, merged via `mergeHooks` (append, never replace) across all twelve collection factories. The layer is right to omit cache revalidation, media reference tracking and audit-log writes — a platform layer must not know a consumer's infrastructure — but it previously offered nowhere to put them back, so the failure mode was silent omission. Untracked media is not merely degraded: it stays eligible for a ghost-pointer sweep while still in use. - **`squadrons.leader` is no longer `required`.** Offboarding a leader with no assistant to promote clears the field; a required constraint makes that write fail validation and blocks member retirement outright. Applies to any consumer with an offboarding path — a vacant leader is a valid state. - **`operation-templates.forceTemplate` added.** Present upstream, absent here, so adoption would have dropped the relationship and orphaned existing rows. Slug-configurable. - **`resource-requests` access restored** to `MANAGE_LOGISTICS | MANAGE_ECONOMY | CREATE_EVENTS`. The prior single-permission check silently revoked access from every economy and events officer, whose roles carry the other two. - **Break-glass integrity bypass**, opt-in via core's `registerSuperRoles`. Off by default, so a site declaring no super-roles keeps the stricter behavior. Note a consumer cannot supply this through `config.hooks` — `mergeHooks` appends, so the layer's hook runs and throws first; the seam is additive-only and cannot suppress layer behavior. Adds the package's first vitest config and test suite. **Requires `@wabbit/tome-core` >= 1.7.0** for `isSuperRoleUser`; the peer range is bumped accordingly.

  • 196d642: Wave 2 absorb — prepares the layer for its first consumer. Each item restores an upstream production-tested behavior this layer had diverged from. - **Consumer hooks seam.** `SCLayerConfig` gains `hooks?:`, merged via `mergeHooks` (append, never replace) across all twelve collection factories. The layer is right to omit cache revalidation, media reference tracking and audit-log writes — a platform layer must not know a consumer's infrastructure — but it previously offered nowhere to put them back, so the failure mode was silent omission. Untracked media is not merely degraded: it stays eligible for a ghost-pointer sweep while still in use. - **`squadrons.leader` is no longer `required`.** Offboarding a leader with no assistant to promote clears the field; a required constraint makes that write fail validation and blocks member retirement outright. Applies to any consumer with an offboarding path — a vacant leader is a valid state. - **`operation-templates.forceTemplate` added.** Present upstream, absent here, so adoption would have dropped the relationship and orphaned existing rows. Slug-configurable. - **`resource-requests` access restored** to `MANAGE_LOGISTICS | MANAGE_ECONOMY | CREATE_EVENTS`. The prior single-permission check silently revoked access from every economy and events officer, whose roles carry the other two. - **Break-glass integrity bypass**, opt-in via core's `registerSuperRoles`. Off by default, so a site declaring no super-roles keeps the stricter behavior. Note a consumer cannot supply this through `config.hooks` — `mergeHooks` appends, so the layer's hook runs and throws first; the seam is additive-only and cannot suppress layer behavior. Adds the package's first vitest config and test suite. **Requires `@wabbit/tome-core` >= 1.7.0** for `isSuperRoleUser`; the peer range is bumped accordingly.
  • @wabbit/tome-cop@0.1.7
v0.1.8patch

Updated dependencies [510036f] - @wabbit/tome-blocks-sc-pack@0.15.0 - @wabbit/tome-blocks-signal-theme@0.15.0

  • Updated dependencies [510036f] - @wabbit/tome-blocks-sc-pack@0.15.0 - @wabbit/tome-blocks-signal-theme@0.15.0
v0.1.7patch

@wabbit/tome-blocks-sc-pack@0.14.0

  • @wabbit/tome-blocks-sc-pack@0.14.0
  • @wabbit/tome-blocks-signal-theme@0.14.0
  • @wabbit/tome-cop@0.1.5
v0.1.6patch

@wabbit/tome-blocks-sc-pack@0.13.0

  • @wabbit/tome-blocks-sc-pack@0.13.0
  • @wabbit/tome-blocks-signal-theme@0.13.0
v0.1.5patch

@wabbit/tome-blocks-signal-theme@0.12.1

  • @wabbit/tome-blocks-signal-theme@0.12.1
  • @wabbit/tome-cop@0.1.5
v0.1.4patch

@wabbit/tome-blocks-sc-pack@0.11.2

  • @wabbit/tome-blocks-sc-pack@0.11.2
  • @wabbit/tome-blocks-signal-theme@0.11.2
v0.1.3patch

26dfa07: `vitest run` exited 1 with "No test files found" in these two packages — neither has any test files yet. Added `--passWithNoTests` to the `test` script so CI doesn't fail on an empty suite. Script-only change; no runtime behavior changed. Both packages still need real test coverage added (tracked separately, not fixed here).

  • 26dfa07: `vitest run` exited 1 with "No test files found" in these two packages — neither has any test files yet. Added `--passWithNoTests` to the `test` script so CI doesn't fail on an empty suite. Script-only change; no runtime behavior changed. Both packages still need real test coverage added (tracked separately, not fixed here).
  • 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.
  • Updated dependencies [6bc419c]
  • Updated dependencies [36e537a]
  • Updated dependencies [5f78397] - @wabbit/tome-blocks-sc-pack@0.11.0 - @wabbit/tome-blocks-signal-theme@0.11.0 - @wabbit/tome-cop@0.1.5
v0.1.2patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here. - @wabbit/tome-cop@0.1.4

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here. - @wabbit/tome-cop@0.1.4
v0.1.1patch

Updated dependencies [ec4b7bc]

  • Updated dependencies [ec4b7bc]
  • Updated dependencies [cd59894] - @wabbit/tome-cop@0.1.4 - @wabbit/tome-blocks-signal-theme@0.10.2 - @wabbit/tome-blocks-sc-pack@0.10.2
v0.1.0minor

Initial release — the Star Citizen wrapper over Tome. **SC data layer** — 12 collections extending `@wabbit/tome-org`: fleet management (`createShipCollection`, `createFleetCollection`, `createFleetLogCollection`, `createAssetAvailabilityCollection`, `createResourceRequestCollection`), SC operations (`createLocationCollection`, `createGameplayActivityCollection`, `createOperationTemplateCollection`, `createForceTemplateCollection`), and military coordination (`createSquadronCollection`, `createTaskForceCollection`, `createTaskForceAssignmentCollection`). Post-hoc extension functions (`withRSIFields`, `withFleetFields`, `withSquadronMembership`, `withSquadronMemberCountSync`, `withSCRankPresets`) wrap org's Member/Event/Membership/Rank collections without modifying `@wabbit/tome-org` itself. Hook-based, opt-in RSI integration (`validateRSIHandle`, `syncMemberOrg`, `createSyncShipDatabase`, the tree-shakeable `./rsi` `RSIClient`, and `./tasks`' `createSyncShipsTask`) — nothing calls any RSI-adjacent service unless a site enables it, and there is no official RSI API (handle checks scrape RSI's own public citizen page; ship data comes from a third-party community API). **SC bundle** — `createSCPlatform(config)` composes org (SC terminology preset + rank presets) + the extensions above + the 12 SC collections + permission registration into one call, and bundles `@wabbit/tome-blocks-sc-pack`, `@wabbit/tome-blocks-signal-theme`, and `@wabbit/tome-cop` as real dependencies. `createSCLayer(config)` is the SC-collections-only convenience function for manual composition. Mints two new platform permissions (`MANAGE_FLEET`, `MANAGE_LOCATIONS`); consumes three of org's existing keys (`MANAGE_STRUCTURE`, `MANAGE_LOGISTICS`, `MANAGE_TEMPLATES`). See the package README for the full quickstart, advanced manual-composition path, the `tome-accounts` co-installation warning, and the documented v1 access-control posture.

  • Initial release — the Star Citizen wrapper over Tome. **SC data layer** — 12 collections extending `@wabbit/tome-org`: fleet management (`createShipCollection`, `createFleetCollection`, `createFleetLogCollection`, `createAssetAvailabilityCollection`, `createResourceRequestCollection`), SC operations (`createLocationCollection`, `createGameplayActivityCollection`, `createOperationTemplateCollection`, `createForceTemplateCollection`), and military coordination (`createSquadronCollection`, `createTaskForceCollection`, `createTaskForceAssignmentCollection`). Post-hoc extension functions (`withRSIFields`, `withFleetFields`, `withSquadronMembership`, `withSquadronMemberCountSync`, `withSCRankPresets`) wrap org's Member/Event/Membership/Rank collections without modifying `@wabbit/tome-org` itself. Hook-based, opt-in RSI integration (`validateRSIHandle`, `syncMemberOrg`, `createSyncShipDatabase`, the tree-shakeable `./rsi` `RSIClient`, and `./tasks`' `createSyncShipsTask`) — nothing calls any RSI-adjacent service unless a site enables it, and there is no official RSI API (handle checks scrape RSI's own public citizen page; ship data comes from a third-party community API). **SC bundle** — `createSCPlatform(config)` composes org (SC terminology preset + rank presets) + the extensions above + the 12 SC collections + permission registration into one call, and bundles `@wabbit/tome-blocks-sc-pack`, `@wabbit/tome-blocks-signal-theme`, and `@wabbit/tome-cop` as real dependencies. `createSCLayer(config)` is the SC-collections-only convenience function for manual composition. Mints two new platform permissions (`MANAGE_FLEET`, `MANAGE_LOCATIONS`); consumes three of org's existing keys (`MANAGE_STRUCTURE`, `MANAGE_LOGISTICS`, `MANAGE_TEMPLATES`). See the package README for the full quickstart, advanced manual-composition path, the `tome-accounts` co-installation warning, and the documented v1 access-control posture.

Blocks Sc Pack

v0.49.8
v0.49.8patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.19.0minor

89a8338: New `./styles.css` export: the five SC blocks now ship a stylesheet for every variant — import it once after `@wabbit/tome-blocks-core/styles.css`. Before this release the blocks emitted their `tome-sc-*` class names but the package shipped no CSS, so they rendered unstyled. The sheet reads only `--tome-*` tokens from `@wabbit/tome-ui`, so it follows the site's theme; status, classification and rank chips map to the semantic success/warning/error/info tokens, and fleet/asset tables scroll inside their own box on narrow screens. No markup or class names changed. Existing sites must add the import to see the styles.

  • 89a8338: New `./styles.css` export: the five SC blocks now ship a stylesheet for every variant — import it once after `@wabbit/tome-blocks-core/styles.css`. Before this release the blocks emitted their `tome-sc-*` class names but the package shipped no CSS, so they rendered unstyled. The sheet reads only `--tome-*` tokens from `@wabbit/tome-ui`, so it follows the site's theme; status, classification and rank chips map to the semantic success/warning/error/info tokens, and fleet/asset tables scroll inside their own box on narrow screens. No markup or class names changed. Existing sites must add the import to see the styles.
  • f6cfffa: Demo/preview data for all five blocks now uses neutral fictional org names, ship manufacturers/models, and locations instead of third-party sci-fi IP terms — no field, prop, slug, or schema changed, only the sample values shown in previews and the auto-gallery.
v0.18.0patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0 - @wabbit/tome-blocks-extras@0.16.0
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-extras@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0
v0.13.0patch

Updated dependencies [f4d55c9]

  • Updated dependencies [f4d55c9]
  • Updated dependencies [eb403d4] - @wabbit/tome-blocks-core@0.13.0 - @wabbit/tome-blocks-extras@0.13.0
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2
v0.11.0patch

6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.

  • 6bc419c: sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta). `./demo` subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.
  • 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-blocks-extras@0.11.0
v0.10.2patch

cd59894: Docs/metadata only — no code or block behavior changes. **signal-theme** — reclassified SC-tier under the `@wabbit/tome-sc` umbrella. `tier` stays `'addon'` (no new tier enum value — already the correct value per the blocks family's 0.7.0 tier-formalization release); ownership is expressed via an `'sc'` bundle tag (added alongside the existing `'star-citizen'` tag) and an updated bundle `description`/README section. This package remains independently installable on any editorial site — its non-SC usability is a bonus, not a design constraint. Also fixes a stale test assertion (`test/smoke.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value. **sc-pack** — README no longer says "scaffold-only" (stale since the 5 blocks were fully authored in Wave 3): documents the real block inventory (`fleet-summary`, `signal-hero-sc`, `task-force-roster`, `op-briefing-panel`, `rsi-handle-card`), corrects the wrong slug list the old README carried, and notes each block is `_variant`-native with self-contained data (no dependency on `@wabbit/tome-sc`'s collection layer). Fixes two stale test assertions (`test/smoke.test.ts`, `test/v2-coverage.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value instead of the shipped `'addon'`.

  • cd59894: Docs/metadata only — no code or block behavior changes. **signal-theme** — reclassified SC-tier under the `@wabbit/tome-sc` umbrella. `tier` stays `'addon'` (no new tier enum value — already the correct value per the blocks family's 0.7.0 tier-formalization release); ownership is expressed via an `'sc'` bundle tag (added alongside the existing `'star-citizen'` tag) and an updated bundle `description`/README section. This package remains independently installable on any editorial site — its non-SC usability is a bonus, not a design constraint. Also fixes a stale test assertion (`test/smoke.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value. **sc-pack** — README no longer says "scaffold-only" (stale since the 5 blocks were fully authored in Wave 3): documents the real block inventory (`fleet-summary`, `signal-hero-sc`, `task-force-roster`, `op-briefing-panel`, `rsi-handle-card`), corrects the wrong slug list the old README carried, and notes each block is `_variant`-native with self-contained data (no dependency on `@wabbit/tome-sc`'s collection layer). Fixes two stale test assertions (`test/smoke.test.ts`, `test/v2-coverage.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value instead of the shipped `'addon'`.
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0
v0.9.5patch

Updated dependencies - @wabbit/tome-blocks-extras@0.9.5

  • Updated dependencies - @wabbit/tome-blocks-extras@0.9.5
v0.9.4patch

Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-extras@0.9.4

  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-extras@0.9.4
v0.9.2patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-blocks-extras@0.9.2
v0.9.1patch

Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1

  • Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1
v0.9.0patch

Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0

  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0
v0.8.0minor

249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.

  • 249b670: Batch 6 (domain packs) + Batch 7 (primitives + usage manifest) of the 2026-06-27 inserter/variant architecture — audit + usage/intent authoring (Decision 4). The domain packs pull from distinct collections (courses / products / members / fleet), so per the spec they stay schema-distinct — this is metadata, not consolidation. - **Authored usage/intent metadata** on all 25 domain blocks: LMS (course-card, lesson-list, progress-bar, quiz-summary, instructor-card, enrollment-cta), Catalog (product-card, product-grid, category-strip, price-table, inventory-badge, featured-product), Org (division-card, member-card, member-grid, event-calendar, event-list-item, org-chart, document-link, campaign-banner), SC (fleet-summary, signal-hero-sc, task-force-roster, op-briefing-panel, rsi-handle-card). Also authored usage on the 9 free `extras-primitives` (section, spacer, grid, stacking-wrapper, content, text-block, code, section-header, content-two-column — in @wabbit/tome-blocks-extras, already bumping). - **Audit (clean):** no dual-mechanism drift, no slug-splits, and the card-vs-grid / item-vs-calendar pairs are genuine single-object-vs-array shape differences (NOT layout variants) — correctly kept as separate blocks. The relationship is encoded in each block's `usage.pairsWith`/`avoidWhen` so an assembling agent picks the right one. - **`buildUsageManifest` verified end-to-end** (@wabbit/tome-blocks-core, Batch 0): builds a sane manifest from the now-authored descriptors — `byRegister` (application / editorial / marketing-landing / structural / dossier), `byPageType`, variant flow-through, and `unauthored` tracking. The consumer-side manifest generation + exposure to assembling agents is a live-run wiring step. Note: the domain packs use the inline-meta pattern (BlockMeta passed to `defineBlock` in each block's index.ts), so `usage` was added there. Ships in the linked family's 0.8.0 minor.
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670] - @wabbit/tome-blocks-extras@0.8.0 - @wabbit/tome-blocks-core@0.8.0
v0.7.0patch

66c611c: B1 — block tiering formalization + blocks-extras free/paid split. - **blocks-core:** add `'addon'` to the `BundleMeta.tier` union (additive; existing `'pro'` values stay valid). - **blocks-extras:** register TWO bundles from one package (Option B) — a free `extras-primitives` sub-bundle (9 structural primitives) + the paid `extras` remainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existing `extras` entitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - **signal-theme + sc-pack:** tier `'pro'` → `'addon'` (sold independently of the tiered subscription track). - **blocks-gallery:** widen the `@wabbit/tome-blocks-core` peer to `^0.5.9 || ^0.6.0 || ^0.7.0` so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch. Release note: the blocks family is `linked`, so this aligns the whole family to **0.7.0**. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (`^0.6` does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.

  • 66c611c: B1 — block tiering formalization + blocks-extras free/paid split. - **blocks-core:** add `'addon'` to the `BundleMeta.tier` union (additive; existing `'pro'` values stay valid). - **blocks-extras:** register TWO bundles from one package (Option B) — a free `extras-primitives` sub-bundle (9 structural primitives) + the paid `extras` remainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existing `extras` entitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - **signal-theme + sc-pack:** tier `'pro'` → `'addon'` (sold independently of the tiered subscription track). - **blocks-gallery:** widen the `@wabbit/tome-blocks-core` peer to `^0.5.9 || ^0.6.0 || ^0.7.0` so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch. Release note: the blocks family is `linked`, so this aligns the whole family to **0.7.0**. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (`^0.6` does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.
  • Updated dependencies [28802fa]
  • Updated dependencies [66c611c]
  • Updated dependencies [8958d41] - @wabbit/tome-blocks-extras@0.7.0 - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2
v0.5.9patch

@wabbit/tome-blocks-core@0.5.9

  • @wabbit/tome-blocks-core@0.5.9
  • @wabbit/tome-blocks-extras@0.5.9
v0.5.7patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
  • @wabbit/tome-blocks-extras@0.5.7
v0.5.0minor

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

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

Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2
v0.4.1patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0
v0.4.0patch

Updated dependencies [b76f684]

  • Updated dependencies [b76f684]
  • Updated dependencies [90a694d] - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-extras@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Blocks Signal Theme

v0.49.4
v0.49.4patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
  • Updated dependencies [deea397] - @wabbit/tome-blocks-console@0.1.3 - @wabbit/tome-ui@0.22.3
v0.43.2patch

d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: Gallery and preview demo props no longer invent site paths. A demo link is now the caller's `placeholderHref` or `#`, so rendered demos stop emitting real `<a href>`s to pages that exist on no site. README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
  • Updated dependencies [d51ad89] - @wabbit/tome-ui@0.22.1 - @wabbit/tome-blocks-console@0.1.2
v0.42.1patch

Updated dependencies [9e7b353] - @wabbit/tome-ui@0.22.0

  • Updated dependencies [9e7b353] - @wabbit/tome-ui@0.22.0
v0.38.0patch

Updated dependencies [a679cc1] - @wabbit/tome-ui@0.21.0 - @wabbit/tome-blocks-console@0.1.2

  • Updated dependencies [a679cc1] - @wabbit/tome-ui@0.21.0 - @wabbit/tome-blocks-console@0.1.2
v0.37.1patch

ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

  • ce84df0: Block descriptions read as plain descriptions of what each block shows, not developer notes. 53 block and variant descriptions carried package names, "Fully static — no peer required", `data-variant`, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it. The developer facts aren't lost: each one moves into the block's `usage.howToUse` (or, where a block has none, a code comment above its description), and the 20 that `howToUse` already covered are simply dropped from the description. CTA's `with-form` variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.
v0.37.0patch

Updated dependencies [d69161c] - @wabbit/tome-ui@0.20.0 - @wabbit/tome-blocks-console@0.1.2

  • Updated dependencies [d69161c] - @wabbit/tome-ui@0.20.0 - @wabbit/tome-blocks-console@0.1.2
v0.36.3patch

1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.

  • 1fd1c73: Eighteen block layouts no longer run their text to the window edge: content sits on the page grid's content lines. These blocks placed their content at `1 / -1`, which only matched the content column when the host wrapped blocks at `2 / -2`. On a host that leaves `defaultColumnSpan` unset, the text sat at the window edge. Full-bleed bands stay full-bleed, and their content now sits on `content-start / content-end`. Affected: editorial-pack's feature walk-strip, message-panel, split-panel (its band now wraps an inner `.splitInner`) and status-board; extras' bento-section, card-grid, content-pro bento, skill-showcase and section-header statement-hero and ruled-split; marketing-starter's banner with no grid placement, cta door-strip, exhibit gallery-wall, and the Full Width layout of feature-hero and cta. In signal-theme, signal-note's aside-left, aside-right and author-overlay sit on the marginalia tracks at 80rem and wider on the page grid; aside-left used to be pulled past the left window edge.
v0.35.0patch

Updated dependencies [f7940ba] - @wabbit/tome-ui@0.19.0 - @wabbit/tome-blocks-console@0.1.2

  • Updated dependencies [f7940ba] - @wabbit/tome-ui@0.19.0 - @wabbit/tome-blocks-console@0.1.2
v0.31.0patch

b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.

  • b8ccf85: Payload-free `./meta` entries now match what each pack registers, and `@wabbit/tome-dispatch`, `@wabbit/tome-longform` and `@wabbit/tome-readout` add a `./demo` entry. `./demo` (dispatch, longform, readout): the root barrel's demo props (`getDemoProps`, the per-block getters, `DemoContext`) on their own subpath, with no Payload import, so a client bundle such as a block gallery can use them without pulling in the block configs. Additive; the root barrel still exports them. These packages ship raw Payload `Block` configs rather than `defineBlock` descriptors, so they have no `./meta` entry. `./meta` (extras, signal-theme): new `extrasDeprecatedBlockMeta` and `signalThemeDeprecatedBlockMeta` lists describe the 16 deprecated blocks each pack still registers for stored content but no longer offers. `extrasBlockMeta` and `signalThemeBlockMeta` are unchanged: they still list only the offered blocks. The deprecated signal-theme blocks' metadata moved from inline `defineBlock` arguments into payload-free `<block>.meta.ts` files; the registered descriptors are unchanged. `./meta` (extras, marketing-starter): six motion blocks (`image-marquee`, `marquee`, `showcase`, `logo-slider`, `pricing-plans`, `testimonial`) now declare `requiredCapabilities: ['motion']` in their metadata. `defineBlock` already added it at registration, so the registered descriptors are unchanged, but the `./meta` entries lacked it and a gallery reading them could not tell these blocks need a motion provider.
  • Updated dependencies [1767d0d] - @wabbit/tome-ui@0.18.0 - @wabbit/tome-blocks-console@0.1.2
v0.28.5patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
  • Updated dependencies [775f90a] - @wabbit/tome-blocks-console@0.1.2 - @wabbit/tome-ui@0.17.1
v0.28.4patch

89abd81: Fifteen deprecated slugs now render through their successor block's renderer, so each look has one implementation; their output, stored fields and exports are unchanged. `signal-classification-banner` and `signal-system-alert` are registered by `SignalBanner`; `signal-comm-intercept` and `signal-comms-transcript` by `SignalComms`; `signal-phase-marker`, `signal-chapter-divider` and `signal-anchor-section` by `SignalDivider`; `signal-cross-link` and `signal-series-nav` by `SignalNav`; `signal-aside`, `signal-author-aside` and `signal-epigraph` by `SignalNote`; `signal-key-facts`, `signal-metric-grid` and `signal-stat-strip` by `SignalStats`. Each is an alias that renders the parent's lane for that slug with the stored block as is, so a document set to a `designVersion` renders exactly as the parent set to the matching `_variant`, and an unset value renders as before. The `renderers` map from `./render/register` keys these slugs to the aliases. Every slug stays a registered block with its own field schema, demo props and catalog entry, and every renderer component is still exported from `./render` with the same props. `signal-callout` keeps its own renderer: it writes its own `_variant` as `data-variant`, which differs from what `signal-note`'s callout variants write. No consumer action is needed.

  • 89abd81: Fifteen deprecated slugs now render through their successor block's renderer, so each look has one implementation; their output, stored fields and exports are unchanged. `signal-classification-banner` and `signal-system-alert` are registered by `SignalBanner`; `signal-comm-intercept` and `signal-comms-transcript` by `SignalComms`; `signal-phase-marker`, `signal-chapter-divider` and `signal-anchor-section` by `SignalDivider`; `signal-cross-link` and `signal-series-nav` by `SignalNav`; `signal-aside`, `signal-author-aside` and `signal-epigraph` by `SignalNote`; `signal-key-facts`, `signal-metric-grid` and `signal-stat-strip` by `SignalStats`. Each is an alias that renders the parent's lane for that slug with the stored block as is, so a document set to a `designVersion` renders exactly as the parent set to the matching `_variant`, and an unset value renders as before. The `renderers` map from `./render/register` keys these slugs to the aliases. Every slug stays a registered block with its own field schema, demo props and catalog entry, and every renderer component is still exported from `./render` with the same props. `signal-callout` keeps its own renderer: it writes its own `_variant` as `data-variant`, which differs from what `signal-note`'s callout variants write. No consumer action is needed.
v0.28.3patch

bd092c2: Fifteen editorial renderers now render through shared cores in `@wabbit/tome-blocks-console`; their look, stored fields and values are unchanged. The renderers are `signal-accordion`, `signal-anchor-section`, `signal-aside`, `signal-author-aside`, `signal-callout`, `signal-chapter-divider`, `signal-cross-link`, `signal-data-table`, `signal-epigraph`, `signal-footnotes`, `signal-image-grid`, `signal-key-facts`, `signal-series-nav`, `signal-spoiler` and `signal-tabbed-content`, and the parents that dispatch to them. `signal-drop-cap` keeps its own renderer. Each keeps its stylesheet, passed to the core as the skin. What changes is for assistive technology and the keyboard: - Tabs: only the selected tab is in the tab order; the arrow keys, Home and End move between tabs; each panel is named by its tab. - Spoiler: the reveal button's spoken name now contains its visible words ("Click to reveal spoiler content", "[REDACTED]: reveal spoiler content"). - Chapter divider `NUMBERED`: the title is a level-2 heading, styled exactly as the text it replaces, and the divider is no longer a separator (a separator's contents are not read). `ORNAMENTAL` and `SYMBOL` stay separators. - Data table: header cells are column headers, and the caption names the table. - Key facts: a fact with a status carries the status word (its option label), visually hidden. - Footnotes: each note carries `id="fn-<number>"` so text can link to it. - Accordion `SINGLE`: only a click on a row's heading opens or closes it, so a click or a link inside an open panel no longer closes the row. No consumer action is needed.

  • bd092c2: Fifteen editorial renderers now render through shared cores in `@wabbit/tome-blocks-console`; their look, stored fields and values are unchanged. The renderers are `signal-accordion`, `signal-anchor-section`, `signal-aside`, `signal-author-aside`, `signal-callout`, `signal-chapter-divider`, `signal-cross-link`, `signal-data-table`, `signal-epigraph`, `signal-footnotes`, `signal-image-grid`, `signal-key-facts`, `signal-series-nav`, `signal-spoiler` and `signal-tabbed-content`, and the parents that dispatch to them. `signal-drop-cap` keeps its own renderer. Each keeps its stylesheet, passed to the core as the skin. What changes is for assistive technology and the keyboard: - Tabs: only the selected tab is in the tab order; the arrow keys, Home and End move between tabs; each panel is named by its tab. - Spoiler: the reveal button's spoken name now contains its visible words ("Click to reveal spoiler content", "[REDACTED]: reveal spoiler content"). - Chapter divider `NUMBERED`: the title is a level-2 heading, styled exactly as the text it replaces, and the divider is no longer a separator (a separator's contents are not read). `ORNAMENTAL` and `SYMBOL` stay separators. - Data table: header cells are column headers, and the caption names the table. - Key facts: a fact with a status carries the status word (its option label), visually hidden. - Footnotes: each note carries `id="fn-<number>"` so text can link to it. - Accordion `SINGLE`: only a click on a row's heading opens or closes it, so a click or a link inside an open panel no longer closes the row. No consumer action is needed.
  • 19a540c: Nine renderers (ship card, personnel card, metric grid, objective list, phase marker, progress bar, stat strip, map legend, sensor readout) now render through the shared cores in `@wabbit/tome-blocks-console`, which this pack already depends on. Their markup, stylesheets, stored fields and look are unchanged, and so is the `signal-stats` and `signal-divider` parents' output where they dispatch to them. No consumer action is needed.
  • 67cdd90: Visible change: an `active` indicator on the threat panel now shows a red dot (`--signal-status-fail`) instead of a green one, so an active threat reads as a threat. Eight renderers (comm intercept, comms transcript, classification banner, log header, redacted, system alert, ambient audio, threat panel) now render through the shared cores in `@wabbit/tome-blocks-console`, a new regular dependency. Their markup, stylesheets and stored fields are otherwise unchanged. Three behaviors for assistive technology change with the cores: the classification banner is a note (`role="note"`) rather than a page banner landmark; only `HULL_BREACH` system alerts are announced (`role="alert"`), the other variants are notes; and the ambient audio label is readable, with only the waveform hidden. The active status word, visible only where a site shows status words, is red to match the dot. No consumer action is needed.
  • Updated dependencies [bd092c2]
  • Updated dependencies [19a540c]
  • Updated dependencies [67cdd90] - @wabbit/tome-blocks-console@0.1.1
v0.28.2patch

d432a85: Signal panels can follow a light theme through the opt-in `@wabbit/tome-ui/console.css`; the comms transcript's red and blue callsigns are now readable on the dark panel. One default changes on purpose: those two callsign colors are lighter, because the old ones failed as text on the dark panel (details at the end). Every read is now `var(--signal-<name>, var(--tome-console-<role>, <today's value>))`. Nothing declares the roles by default, so every block computes the color it did before, and a `--signal-*` value set by a site or by `@wabbit/tome-cop` still wins. Composition roles let the light set match the approved board: a single 2px hue rule on the threat, sensor, objective and comms panels (today's 1px neutral top edge when unset), hairlines round metric cards and callouts, a neutral threat outline, no washes behind the threat badge, system alert, DECLASSIFIED band and personnel badges, outlined badges, an unfilled RESTRICTED band framed by rules (SECRET stays filled), and no dimming on redacted or degraded lines. Each is zero-width, today's percentage or today's opacity when unset. The mapping is in the README. Threat indicators and the sensor readout's SYSTEM cards now carry a status word (the field's option label), and objective items carry theirs for screen readers. The word is always in the markup and visually hidden by default (the clip pattern, so the layout does not change); threat and sensor words show when `--tome-console-status-word-display` is set. Deliberate default change: the comms transcript's red and blue callsign colors were `hsl(0 72% 50%)` (3.71:1 on the dark panel) and `hsl(215 65% 45%)` (3.23:1). They now use the pack's on-panel values, `hsl(0 80% 68%)` (6.07:1) and `hsl(215 75% 65%)` (6.36:1). Transcripts using gold, cyan, amber, green or ghost callsigns render as before.

  • d432a85: Signal panels can follow a light theme through the opt-in `@wabbit/tome-ui/console.css`; the comms transcript's red and blue callsigns are now readable on the dark panel. One default changes on purpose: those two callsign colors are lighter, because the old ones failed as text on the dark panel (details at the end). Every read is now `var(--signal-<name>, var(--tome-console-<role>, <today's value>))`. Nothing declares the roles by default, so every block computes the color it did before, and a `--signal-*` value set by a site or by `@wabbit/tome-cop` still wins. Composition roles let the light set match the approved board: a single 2px hue rule on the threat, sensor, objective and comms panels (today's 1px neutral top edge when unset), hairlines round metric cards and callouts, a neutral threat outline, no washes behind the threat badge, system alert, DECLASSIFIED band and personnel badges, outlined badges, an unfilled RESTRICTED band framed by rules (SECRET stays filled), and no dimming on redacted or degraded lines. Each is zero-width, today's percentage or today's opacity when unset. The mapping is in the README. Threat indicators and the sensor readout's SYSTEM cards now carry a status word (the field's option label), and objective items carry theirs for screen readers. The word is always in the markup and visually hidden by default (the clip pattern, so the layout does not change); threat and sensor words show when `--tome-console-status-word-display` is set. Deliberate default change: the comms transcript's red and blue callsign colors were `hsl(0 72% 50%)` (3.71:1 on the dark panel) and `hsl(215 65% 45%)` (3.23:1). They now use the pack's on-panel values, `hsl(0 80% 68%)` (6.07:1) and `hsl(215 75% 65%)` (6.36:1). Transcripts using gold, cyan, amber, green or ghost callsigns render as before.
  • Updated dependencies [d432a85] - @wabbit/tome-ui@0.17.0
v0.28.1patch

8d4b2a6: Signal blocks now read their accent, status, redaction, banner and threat colors through named `--signal-*` variables, so a site can re-color them; nothing changes until you set one. Every color keeps today's value as the `var()` fallback, and no variable is declared at `:root`. The seven accent hues are applied through one shared helper instead of a pasted `.accent-<hue>` rule in each of 27 stylesheets; each hue's three roles resolve to the same values as before. The new variable names are listed in the README token tables.

  • 8d4b2a6: Signal blocks now read their accent, status, redaction, banner and threat colors through named `--signal-*` variables, so a site can re-color them; nothing changes until you set one. Every color keeps today's value as the `var()` fallback, and no variable is declared at `:root`. The seven accent hues are applied through one shared helper instead of a pasted `.accent-<hue>` rule in each of 27 stylesheets; each hue's three roles resolve to the same values as before. The new variable names are listed in the README token tables.
v0.28.0patch

d08fc38: The sensor readout block's contacts table now scrolls inside its own keyboard-reachable region instead of widening the page on phones. The region is labeled with the block title (or Sensor contacts). The grid layout's card minimum also caps at the container width.

  • d08fc38: The sensor readout block's contacts table now scrolls inside its own keyboard-reachable region instead of widening the page on phones. The region is labeled with the block title (or Sensor contacts). The grid layout's card minimum also caps at the container width.
  • 58655f4: Blocks now carry the neutral Tome source tag instead of the legacy source tag, so the gallery lists them as Tome blocks; stored content is unchanged.
v0.27.0patch

c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.

  • c14a133: The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin. Each renderer's `.tome-css` stylesheet is compiled when the pack is built, into a JS module next to it (`<Name>.tome-css.js` / `.cjs`), and the renderers import that. A site no longer has to wrap next.config with `withTomeBlockStyles` to use the pack, and `./render` and `./render/register` now load under plain Node, so seed scripts, tests and the Payload CLI can import them. Each block's CSS is still inlined only on pages that render the block. A site that already uses `withTomeBlockStyles` needs no change. Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw `.tome-css` files stay in the package as readable source.
  • Updated dependencies [c14a133] - @wabbit/tome-ui@0.16.0
v0.26.0patch

Updated dependencies [8c84e70] - @wabbit/tome-ui@0.15.0

  • Updated dependencies [8c84e70] - @wabbit/tome-ui@0.15.0
v0.23.0minor

**Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 33 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - SignalAccordion, SignalSpoiler and SignalTabbedContent are client components; each is now a server-safe wrapper (`X.tsx`) around `X.client.tsx`. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

  • **Breaking: block stylesheets are now per-block (`.tome-css`).** Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle. The 33 stylesheets moved from `X.module.css` to `X.tome-css`, and each renderer renders `<BlockStyles sheet={styles} />` from `@wabbit/tome-blocks-core/block-styles`. **Required in the consuming site:** wrap next.config with `withTomeBlockStyles` (`@wabbit/tome-blocks-core/next`, blocks-core 0.22.0 or later); without it the `.tome-css` imports fail to build. See the blocks-core README, "Per-block stylesheets". - The `@wabbit/tome-blocks-core` peer range is now `>=0.22.0 <1.0.0`. - SignalAccordion, SignalSpoiler and SignalTabbedContent are client components; each is now a server-safe wrapper (`X.tsx`) around `X.client.tsx`. Exported names and props are unchanged. - Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.
v0.20.0patch

9ac8d3d: Signal aside and author aside now get their wide-screen outdent. They read the undeclared `--tome-space-xxxl` and now read `--tome-space-3xl`. The ambient-audio and progress-bar `--tome-motion-normal` timings now resolve because `@wabbit/tome-ui` declares that token.

  • 9ac8d3d: Signal aside and author aside now get their wide-screen outdent. They read the undeclared `--tome-space-xxxl` and now read `--tome-space-3xl`. The ambient-audio and progress-bar `--tome-motion-normal` timings now resolve because `@wabbit/tome-ui` declares that token.
  • Updated dependencies [9f6b52c] - @wabbit/tome-ui@0.14.0
v0.18.3patch

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behavior change, and the published `dist/` is identical.
  • 786853e: `signal-accordion`'s Single Expand variant now always opens one panel at a time, and `signal-callout`'s Lore variant always renders the Lore treatment. Before, both variants only set `data-variant`; the look still came from the in-block `designVersion` select, so picking a variant changed nothing visible. The renderers now map `_variant` onto `designVersion` the way `signal-note` does: `single` → `SINGLE`, `lore` → `LORE`. The `default` variant (now labeled "Default") still follows `designVersion`, so documents on the default variant render as before. A saved document on `single` or `lore` whose `designVersion` disagreed now renders the variant it names. The `designVersion` select is hidden in the admin while a forcing variant is picked. `data-variant` is still emitted.
v0.18.1patch

89161af: The gallery now shows `signal-accordion`'s two variants, and the accordion and callout descriptions name their registered variants. `signal-accordion`'s variants moved to a payload-free `signal-accordion.variants.ts` so `signalThemeBlockMeta` can list them. The package description and bundle description now give the real count: 39 registered, 23 offered.

  • 89161af: The gallery now shows `signal-accordion`'s two variants, and the accordion and callout descriptions name their registered variants. `signal-accordion`'s variants moved to a payload-free `signal-accordion.variants.ts` so `signalThemeBlockMeta` can list them. The package description and bundle description now give the real count: 39 registered, 23 offered.
v0.18.0patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.

  • c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behavior and signature are unchanged. The `@wabbit/tome-blocks-core` peer floor goes up to `>=0.18.0` because that is the first version exporting the helper.
v0.17.0minor

404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.

  • 404d325: Tome block packs now install into an existing Payload project the way the README says: one `npm install`, one CSS import, no undocumented steps. Proven by the new fresh-install smoke test (`scripts/blocks-fresh-install-smoke.mjs`) against a brand-new `create-payload-app` website-template site. **Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json`** if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh `npm install` of a pack already brings them in). - **One shared `blocks-core` per site.** Every pack, `blocks-house` and `blocks-extras` now declare `@wabbit/tome-blocks-core` (and, where used, `-house` / `-extras`) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - **No more ERESOLVE in plain Payload sites.** `blocks-core` no longer declares `@wabbit/tome-core` or `@wabbit/tome-catalog` (their optional peer graph pulled `better-auth` → `@sveltejs/kit` → `vite@8` against a site's `vite@7`). The `block-bundle` product type still auto-registers when both are installed; new structural types `BlockBundleProductTypeDeps`, `BlockBundleProductTypeRegistryLike`, `RegisterProductTypeHooksLike`. - **Tokens in one line:** `@import '@wabbit/tome-blocks-core/styles.css';` (new export; imports `@wabbit/tome-ui/tokens`). `@wabbit/tome-ui` is now a required peer of `blocks-core`. - **Rich text and images render with no adapter setup.** Built-in defaults render Lexical through `@payloadcms/richtext-lexical/react` and resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - **Payload's spread-props convention:** new `adaptRenderersForPayload(renderers)` / `adaptRendererForPayload(Component)` wrap any pack's `renderers` map for a site that renders `<Block {...block} />`. - **Slug collisions with Payload's templates** (`cta`, `banner`, `archive`, `content`, `code`): new `applyBlockSlugOverrides(blocks, overrides)` and `remapRendererSlugs(renderers, overrides)` (`@wabbit/tome-blocks-core/slugOverrides`). Defaults are unchanged; no stored data migrates. - **`blocks-house`** owns `gsap` and `hls.js` as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's `ScrollTrigger` itself before first use. - **Full-bleed bands actually span the grid.** Eight `pinnedBand` blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare `grid-column: 1 / -1` at their root as the contract requires. **Visible change:** inside a tome-ui `.grid`, these render edge to edge where they were previously squeezed to content width. - **`@wabbit/tome-ui`:** `.grid` declares `reading-start` / `reading-end` below 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches `package.json`; `blocks-core`'s README carries the full walkthrough.
  • Updated dependencies [404d325] - @wabbit/tome-ui@0.13.1
v0.16.1patch

Updated dependencies [befde64] - @wabbit/tome-ui@0.13.0

  • Updated dependencies [befde64] - @wabbit/tome-ui@0.13.0
v0.16.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` across the linked blocks family. Every pack advertised `react: >=18.0.0` while `@wabbit/tome-core`, `chrome`, `forms`, `dispatch`, `longform` and `readout` all require `>=19` — so the React 18 support the packs claimed was **unreachable in any real Tome stack**: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into. Ruled 2026-09-01: the floor becomes the truth. Their `devDependencies` said the same thing from the other direction: `react` and `@types/react` pinned to `^18.0.0` while the root `pnpm.overrides` has pinned `@types/react` to `19.2.14` for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to `^19.0.0` — a manifest correction, not a version change; the resolved tree is byte-identical. One coordinated bump for the family (these eleven are `linked` in `.changeset/config.json`, so they version together by design). Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph. Anyone on React 19 sees no change.
  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0 - @wabbit/tome-blocks-extras@0.16.0 - @wabbit/tome-ui@0.12.0
v0.15.12patch

54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 54ff357: Republish with correctly-built artifacts, and close the hole that made it necessary. The 0.15.11 line shipped `defaultBreakout` in its version notes but **not in its tarballs**: source on main carried all 151 descriptors, the published `dist/` carried zero. `pnpm publish` does not build — it packs whatever is already in `dist/` — so a release ships whatever a previous, possibly unrelated, build left behind. Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source. Caught by grepping the _installed_ package in a consumer rather than trusting the version number. **Systemic fix:** `prepublishOnly: pnpm run build` added to all 41 publishable packages that lacked it (only `blocks-org-pack` had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely. Same family as the two publish hazards already documented in this repo (`workspace:*` literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.
  • Updated dependencies [54ff357] - @wabbit/tome-blocks-extras@0.15.12
v0.15.11patch

1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).

  • 1bebcdc: Populate `BlockMeta.defaultBreakout` across all nine block packs — 151 descriptors now declare their natural width on the page grid. The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined `defaultBreakout` as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to `'article'` (the reading column) and a full-bleed hero previewed at prose width. Values are drawn from the canonical `@wabbit/tome-ui` `BreakoutWidthValue` vocabulary and assigned from each block's render CSS, not its name: - Root at a named grid line (`content-start / content-end`, `full-start / full-end`, `marginalia-right-*`, …) — `defaultBreakout` mirrors that exact line. - Root `1 / -1` + subgrid with an inner wrapper at `content-start / content-end` — a self-banding block: `'full-bleed'`. - Root and inner both `1 / -1` (width-agnostic) — assigned editorially: bands/heroes `'full-bleed'`, page sections `'content'`, cards and single-column components `'breakout-md'`, prose/inline components `'article'`. - Where a block already ships its own `breakoutWidthField({ defaultValue })`, `defaultBreakout` matches that value exactly rather than contradicting it. Distribution: `content` 52, `full-bleed` 41, `article` 33, `breakout-md` 23, `breakout-lg` 1, `marginalia-right` 1. Also declares `pinnedBand: true` on the three blocks that are unambiguously full-bleed bands whose own `breakoutWidth` field drives INNER content (`compareColumns`, `editorialSection`, `editorialSpread`), and `nestable: false` on 22 full-bleed heroes / band primitives / containers that carried no `nestable` declaration. Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing `nestable: true` was flipped, so the derived `layoutGrid` child allowlist is byte-identical (98 nestable blocks before and after).
  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
  • Updated dependencies [1bebcdc]
  • Updated dependencies [48773ac] - @wabbit/tome-blocks-extras@0.15.11 - @wabbit/tome-ui@0.11.2
v0.15.9patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
  • Updated dependencies [71d3b09] - @wabbit/tome-blocks-extras@0.15.9 - @wabbit/tome-blocks-core@0.15.9 - @wabbit/tome-ui@0.11.1
v0.15.4patch

Updated dependencies [0a070e0] - @wabbit/tome-ui@0.11.0

  • Updated dependencies [0a070e0] - @wabbit/tome-ui@0.11.0
v0.15.0minor

510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.

  • 510036f: Route pack block links through the `LinkAdapter` instead of raw `<a href>`. `LinkAdapter` (0.14.0) shipped the seam; this connects it. **67 anchors across 55 renderer files in 10 packs** now render through `<TomeLink>`, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree. **New in `@wabbit/tome-blocks-core`: `<TomeLink>`**, a component form of `resolveLink()`. `resolveRichText()` / `resolveMedia()` are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through a `children:` prop. `<TomeLink href={…}>…</TomeLink>` is a drop-in for the `<a>` it replaces. It delegates to `resolveLink()`, so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components. **`LinkProps.href` is now `string | null | undefined`.** Block data routinely carries an optional URL, and the raw `<a href={undefined}>` these calls replaced was legal markup. Narrowing it to `string` would have forced ~10 non-null assertions across the packs and changed behavior at each. `NOOP_LINK_ADAPTER` normalizes null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behavior is unchanged for every consumer that has not registered a link adapter**, which is currently all of them: `resolveLink` falls back to a plain `<a>`. Deliberately left as raw `<a>`: - `EditorialFootnotes` — its `#fnref-*` anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document. - `PricingPlans` / `PricingPlanCard` — these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter. - `LogoSlider` — a self-closing, childless `target="_blank"` overlay anchor. Always external, so the adapter would hand it straight back to the browser. - `@wabbit/tome-longform` — it has zero runtime dependencies and does not peer on `blocks-core`. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0 - @wabbit/tome-blocks-extras@0.15.0
v0.14.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0 - @wabbit/tome-blocks-extras@0.14.0
v0.13.0patch

Updated dependencies [f4d55c9]

  • Updated dependencies [f4d55c9]
  • Updated dependencies [eb403d4] - @wabbit/tome-blocks-core@0.13.0 - @wabbit/tome-blocks-extras@0.13.0
v0.12.1patch

Updated dependencies - @wabbit/tome-ui@0.10.0

  • Updated dependencies - @wabbit/tome-ui@0.10.0
v0.11.2patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2 - @wabbit/tome-blocks-extras@0.11.2
v0.11.0patch

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: 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.
  • 5f78397: The clientization migration: 127 render components across seven packs dropped `'use client'` — every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted to `resolveRichText`/`resolveMedia`. Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new `assert:rsc-boundaries` CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports `./render/register` (`renderers` map + explicit `registerRenderers()`), aggregated by `@wabbit/tome-blocks`'s new `registerAllRenderers()` — the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger). `RenderBlock` is rewritten server-safe: directive-free, optional `components` prop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling.
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0 - @wabbit/tome-blocks-extras@0.11.0 - @wabbit/tome-ui@0.9.9
v0.10.2patch

cd59894: Docs/metadata only — no code or block behavior changes. **signal-theme** — reclassified SC-tier under the `@wabbit/tome-sc` umbrella. `tier` stays `'addon'` (no new tier enum value — already the correct value per the blocks family's 0.7.0 tier-formalization release); ownership is expressed via an `'sc'` bundle tag (added alongside the existing `'star-citizen'` tag) and an updated bundle `description`/README section. This package remains independently installable on any editorial site — its non-SC usability is a bonus, not a design constraint. Also fixes a stale test assertion (`test/smoke.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value. **sc-pack** — README no longer says "scaffold-only" (stale since the 5 blocks were fully authored in Wave 3): documents the real block inventory (`fleet-summary`, `signal-hero-sc`, `task-force-roster`, `op-briefing-panel`, `rsi-handle-card`), corrects the wrong slug list the old README carried, and notes each block is `_variant`-native with self-contained data (no dependency on `@wabbit/tome-sc`'s collection layer). Fixes two stale test assertions (`test/smoke.test.ts`, `test/v2-coverage.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value instead of the shipped `'addon'`.

  • cd59894: Docs/metadata only — no code or block behavior changes. **signal-theme** — reclassified SC-tier under the `@wabbit/tome-sc` umbrella. `tier` stays `'addon'` (no new tier enum value — already the correct value per the blocks family's 0.7.0 tier-formalization release); ownership is expressed via an `'sc'` bundle tag (added alongside the existing `'star-citizen'` tag) and an updated bundle `description`/README section. This package remains independently installable on any editorial site — its non-SC usability is a bonus, not a design constraint. Also fixes a stale test assertion (`test/smoke.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value. **sc-pack** — README no longer says "scaffold-only" (stale since the 5 blocks were fully authored in Wave 3): documents the real block inventory (`fleet-summary`, `signal-hero-sc`, `task-force-roster`, `op-briefing-panel`, `rsi-handle-card`), corrects the wrong slug list the old README carried, and notes each block is `_variant`-native with self-contained data (no dependency on `@wabbit/tome-sc`'s collection layer). Fixes two stale test assertions (`test/smoke.test.ts`, `test/v2-coverage.test.ts`) still expecting the pre-0.7.0 `tier: 'pro'` value instead of the shipped `'addon'`.
  • Updated dependencies [ec4b7bc] - @wabbit/tome-ui@0.9.8
v0.10.0patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0 - @wabbit/tome-blocks-extras@0.10.0
v0.9.5patch

Updated dependencies - @wabbit/tome-blocks-extras@0.9.5

  • Updated dependencies - @wabbit/tome-blocks-extras@0.9.5
v0.9.4patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4 - @wabbit/tome-blocks-extras@0.9.4 - @wabbit/tome-ui@0.9.7
v0.9.3patch

Updated dependencies - @wabbit/tome-ui@0.9.6

  • Updated dependencies - @wabbit/tome-ui@0.9.6
v0.9.2patch

D3 convergence: all five signal blocks (data-table, metric-grid, image-grid, stat-strip, stats) resolve width via the canonical `resolveBreakout()` from `@wabbit/tome-ui`; local per-block `.breakout-*` CSS strategies removed; stat-strip/stats default `article`→`content`.

  • D3 convergence: all five signal blocks (data-table, metric-grid, image-grid, stat-strip, stats) resolve width via the canonical `resolveBreakout()` from `@wabbit/tome-ui`; local per-block `.breakout-*` CSS strategies removed; stat-strip/stats default `article`→`content`.
  • Updated dependencies
  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2 - @wabbit/tome-ui@0.9.5 - @wabbit/tome-blocks-extras@0.9.2
v0.9.1patch

Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1

  • Updated dependencies [c07f3c8] - @wabbit/tome-blocks-extras@0.9.1
v0.9.0minor

c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.

  • c7d0afc: layoutGrid nesting capability audit + render hardening (Wave 2). Tags the `nestable` capability flag (and a `minColSpan` floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the `layoutGrid` container's derived child allowlist — `blockRegistry.getNestableBlocks()` — is populated rather than empty. Excluded by design (left non-nestable): containers (`editorialSpread`, `editorialSection`, `split-view`, `stacking-wrapper`, `grid`, `layoutGrid`) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children). `minColSpan: 2` is set on the internally multi-column blocks (`card-grid`, `bento-section`, `content-two-column`, `signal-stats`, `signal-image-grid`, `signal-data-table`) so they cannot be crammed into a single-column cell. Also hardens `LayoutGrid`'s child↔span pairing: children are now flattened with null slots preserved (instead of `React.Children.toArray`, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count. `@wabbit/tome-blocks-core` is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's `0.8.0` predates that merge): `BlockMeta.nestable`/`minColSpan`, `withChildPlacement`, the reserved `_colSpan`/`_rowSpan`/`_order` field constants, and `blockRegistry.getNestableBlocks()`. Without this, a consumer wiring the layoutGrid allowlist would call a `getNestableBlocks` that its installed `blocks-core@0.8.0` does not have. Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0 - @wabbit/tome-blocks-extras@0.9.0
v0.8.0minor

249b670: Batch 5 — signal-theme consolidation (2026-06-27 inserter/variant architecture). The signal pack is the largest collapse: ~33 blocks, almost all encoding their variant space in an inline `designVersion` select. This batch lifts that into the real `_variant` axis (designVersion deprecated, Decision 1) and merges thin themed wrappers into 6 variant parents — **16 source blocks → 6 parents** (offered set ~33 → ~23). - **New `signal-banner`** — merges `signal-classification-banner` + `signal-system-alert` into one block with 7 `_variant`s (declassified default / restricted / secret / hull-breach / quantum / comms / all-clear). - **New `signal-note`** — merges `signal-callout` + `signal-aside` + `signal-author-aside` + `signal-epigraph` (the headline "thin themed wrappers") into one block with 12 `_variant`s (tactical default / info / lore / log / aside-_ / author-_ / epigraph-\*). Also reconciles `signal-callout`'s dual-mechanism drift (it carried both an inline `designVersion` AND a `_variant` array — both folded into the single `_variant`, lossless). - **New `signal-stats`** ← key-facts + metric-grid + stat-strip (9 `_variant`s). **New `signal-comms`** ← comm-intercept + comms-transcript (6). **New `signal-divider`** ← phase-marker + chapter-divider + anchor-section (9). **New `signal-nav`** ← cross-link + series-nav (5). - Every parent maps `_variant` → the legacy `designVersion` value and dispatches to the existing render components, so visuals are preserved exactly; the Batch-0 `{variant→component}` registry is populated. Where two sources' render keyed on different field names (e.g. `title`/`heading`, `title`/`seriesTitle`), the render re-projects the shared field. Authored usage/intent on all six. - **Deprecated** the 16 source blocks (still registered + rendered for back-compat via the pack's new DEPRECATED_BLOCKS set, removed from the offered bundle). Instance migration → parent + `_variant` is deferred to a later release (a live migration run). - **Kept distinct** (genuinely different shapes): data-table, progress-bar, objective-list, map-legend, personnel-card, ship-card, log-header, image-grid, footnotes, ambient-audio, sensor-readout, threat-panel, accordion, tabbed-content. - **`signal-drop-cap` / `signal-spoiler` / `signal-redacted`** are slated for a SEPARATE workstream — re-modeling as Lexical inline marks, not blocks — so they remain offered for now. Ships in the linked family's 0.8.0 minor.

  • 249b670: Batch 5 — signal-theme consolidation (2026-06-27 inserter/variant architecture). The signal pack is the largest collapse: ~33 blocks, almost all encoding their variant space in an inline `designVersion` select. This batch lifts that into the real `_variant` axis (designVersion deprecated, Decision 1) and merges thin themed wrappers into 6 variant parents — **16 source blocks → 6 parents** (offered set ~33 → ~23). - **New `signal-banner`** — merges `signal-classification-banner` + `signal-system-alert` into one block with 7 `_variant`s (declassified default / restricted / secret / hull-breach / quantum / comms / all-clear). - **New `signal-note`** — merges `signal-callout` + `signal-aside` + `signal-author-aside` + `signal-epigraph` (the headline "thin themed wrappers") into one block with 12 `_variant`s (tactical default / info / lore / log / aside-_ / author-_ / epigraph-\*). Also reconciles `signal-callout`'s dual-mechanism drift (it carried both an inline `designVersion` AND a `_variant` array — both folded into the single `_variant`, lossless). - **New `signal-stats`** ← key-facts + metric-grid + stat-strip (9 `_variant`s). **New `signal-comms`** ← comm-intercept + comms-transcript (6). **New `signal-divider`** ← phase-marker + chapter-divider + anchor-section (9). **New `signal-nav`** ← cross-link + series-nav (5). - Every parent maps `_variant` → the legacy `designVersion` value and dispatches to the existing render components, so visuals are preserved exactly; the Batch-0 `{variant→component}` registry is populated. Where two sources' render keyed on different field names (e.g. `title`/`heading`, `title`/`seriesTitle`), the render re-projects the shared field. Authored usage/intent on all six. - **Deprecated** the 16 source blocks (still registered + rendered for back-compat via the pack's new DEPRECATED_BLOCKS set, removed from the offered bundle). Instance migration → parent + `_variant` is deferred to a later release (a live migration run). - **Kept distinct** (genuinely different shapes): data-table, progress-bar, objective-list, map-legend, personnel-card, ship-card, log-header, image-grid, footnotes, ambient-audio, sensor-readout, threat-panel, accordion, tabbed-content. - **`signal-drop-cap` / `signal-spoiler` / `signal-redacted`** are slated for a SEPARATE workstream — re-modeling as Lexical inline marks, not blocks — so they remain offered for now. Ships in the linked family's 0.8.0 minor.
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670]
  • Updated dependencies [249b670] - @wabbit/tome-blocks-extras@0.8.0 - @wabbit/tome-blocks-core@0.8.0
v0.7.0patch

66c611c: B1 — block tiering formalization + blocks-extras free/paid split. - **blocks-core:** add `'addon'` to the `BundleMeta.tier` union (additive; existing `'pro'` values stay valid). - **blocks-extras:** register TWO bundles from one package (Option B) — a free `extras-primitives` sub-bundle (9 structural primitives) + the paid `extras` remainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existing `extras` entitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - **signal-theme + sc-pack:** tier `'pro'` → `'addon'` (sold independently of the tiered subscription track). - **blocks-gallery:** widen the `@wabbit/tome-blocks-core` peer to `^0.5.9 || ^0.6.0 || ^0.7.0` so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch. Release note: the blocks family is `linked`, so this aligns the whole family to **0.7.0**. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (`^0.6` does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.

  • 66c611c: B1 — block tiering formalization + blocks-extras free/paid split. - **blocks-core:** add `'addon'` to the `BundleMeta.tier` union (additive; existing `'pro'` values stay valid). - **blocks-extras:** register TWO bundles from one package (Option B) — a free `extras-primitives` sub-bundle (9 structural primitives) + the paid `extras` remainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existing `extras` entitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - **signal-theme + sc-pack:** tier `'pro'` → `'addon'` (sold independently of the tiered subscription track). - **blocks-gallery:** widen the `@wabbit/tome-blocks-core` peer to `^0.5.9 || ^0.6.0 || ^0.7.0` so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch. Release note: the blocks family is `linked`, so this aligns the whole family to **0.7.0**. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (`^0.6` does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.
  • Updated dependencies [28802fa]
  • Updated dependencies [66c611c]
  • Updated dependencies [8958d41] - @wabbit/tome-blocks-extras@0.7.0 - @wabbit/tome-blocks-core@0.7.0
v0.6.2patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2 - @wabbit/tome-blocks-extras@0.6.2
v0.6.1patch

f37fa00: feat(demo): add getDemoProps dispatchers to agency-essentials, marketing-starter, and signal-theme Each pack now ships a `demo.ts` barrel with a `getDemoProps(blockSlug, variant, ctx?)` dispatcher and per-block demo functions. The auto-gallery route in tome-starter can replace the `noopDemoProps` stub for all three bundles, eliminating the warning cards that appeared for every block × variant. - agency-essentials: 10 blocks × 4 variants each (about, contact, team-roster, gallery, timeline, stat, stat-bar, split-view, media, form) - marketing-starter: 8 blocks × 4-5 variants each (high-impact-hero, feature-hero, cta, logo-slider, pricing, testimonial, faq, banner) - signal-theme: 33 blocks (all signal-\* slugs); blocks with multi-variant configs covered (accordion: stacked/single, callout: tactical/lore) - DemoContext interface, individual block-level functions, and getDemoProps all re-exported from each pack barrel - richText fields intentionally omitted — GalleryRichTextProvider supplies the Lexical state at gallery render time - Relationship fields (pricing, testimonial) emit sentinel strings; gallery degrades gracefully Fixes risk R1 from the gallery gap audit. - @wabbit/tome-blocks-core@0.5.9

  • f37fa00: feat(demo): add getDemoProps dispatchers to agency-essentials, marketing-starter, and signal-theme Each pack now ships a `demo.ts` barrel with a `getDemoProps(blockSlug, variant, ctx?)` dispatcher and per-block demo functions. The auto-gallery route in tome-starter can replace the `noopDemoProps` stub for all three bundles, eliminating the warning cards that appeared for every block × variant. - agency-essentials: 10 blocks × 4 variants each (about, contact, team-roster, gallery, timeline, stat, stat-bar, split-view, media, form) - marketing-starter: 8 blocks × 4-5 variants each (high-impact-hero, feature-hero, cta, logo-slider, pricing, testimonial, faq, banner) - signal-theme: 33 blocks (all signal-\* slugs); blocks with multi-variant configs covered (accordion: stacked/single, callout: tactical/lore) - DemoContext interface, individual block-level functions, and getDemoProps all re-exported from each pack barrel - richText fields intentionally omitted — GalleryRichTextProvider supplies the Lexical state at gallery render time - Relationship fields (pricing, testimonial) emit sentinel strings; gallery degrades gracefully Fixes risk R1 from the gallery gap audit. - @wabbit/tome-blocks-core@0.5.9
v0.5.9patch

@wabbit/tome-blocks-core@0.5.9

  • @wabbit/tome-blocks-core@0.5.9
  • @wabbit/tome-blocks-extras@0.5.9
v0.5.7patch

@wabbit/tome-blocks-core@0.5.7

  • @wabbit/tome-blocks-core@0.5.7
  • @wabbit/tome-blocks-extras@0.5.7
v0.5.0minor

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

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

Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.2 - @wabbit/tome-blocks-core@0.4.2
v0.4.1patch

Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0

  • Updated dependencies - @wabbit/tome-blocks-extras@0.4.1 - @wabbit/tome-blocks-core@0.4.0
v0.4.0patch

Updated dependencies [b76f684]

  • Updated dependencies [b76f684]
  • Updated dependencies [90a694d] - @wabbit/tome-blocks-core@0.4.0 - @wabbit/tome-blocks-extras@0.4.0
v0.3.0minor

f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```

  • f2202cd: Sprint 3 blocks split — v0.2.0 Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation. **New packages (all v0.2.0):** - `@wabbit/tome-blocks-core` — registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker) - `@wabbit/tome-blocks-marketing-starter` (free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner - `@wabbit/tome-blocks-content-writer` (free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes - `@wabbit/tome-blocks-agency-essentials` (starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline - `@wabbit/tome-blocks-editorial-pack` (pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal - `@wabbit/tome-blocks-signal-theme` (pro) — 33 Signal narrative blocks - `@wabbit/tome-blocks-lms-pack` (pro) — scaffold for LMS blocks (Wave 3) - `@wabbit/tome-blocks-catalog-pack` (pro) — scaffold for catalog/ecommerce blocks (Wave 3) - `@wabbit/tome-blocks-sc-pack` (niche) — scaffold for Star Citizen blocks (Wave 3) - `@wabbit/tome-blocks-extras` (pro) — 47 residual blocks (heroes, layout, content, marketing) **@wabbit/tome-blocks is now a meta-package** that re-exports all bundle packages and provides `registerAll(blockRegistry, bundleRegistry)` as a convenience function. **Render colocation:** All 113 render `.tsx` components migrated from the monolith into their owning bundle packages (`./render` subpath on each bundle). **Breaking changes (internal):** - `@wabbit/tome-blocks/blocks` and `@wabbit/tome-blocks/bundles` subpaths removed (were Sprint 2 shims) - `packages/blocks/src/render/` category index files removed (replaced by per-bundle `./render` subpaths) **Migration:** ```ts // Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry); ```
  • Updated dependencies [f2202cd] - @wabbit/tome-blocks-core@0.3.0

Cop

v0.2.5
v0.2.5patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.2.4patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.3patch

b5d68d0: The COP type scale is re-spaced two points larger at the small end, every text step gains line-height and tracking companion tokens, and the faintest text gray now clears WCAG AA. - `--cop-text-micro/nano/label/control/body/data/subheading/title` move from 7/8/9/10/11/12/13/14px to 9/10/11/12/13/14/15/16px; `--cop-text-panel` moves from 16px to 17px so it stays distinct from the new title size. - New `--cop-text-<step>--line-height` for every text step (Tailwind v4 reads these) and `--cop-text-<step>--tracking` for micro through title. - `--muted-foreground` lightens from `oklch(55% 0.01 240)` to `oklch(60% 0.01 240)`: 3.94:1 → 4.85:1 on the COP card surface.

  • b5d68d0: The COP type scale is re-spaced two points larger at the small end, every text step gains line-height and tracking companion tokens, and the faintest text gray now clears WCAG AA. - `--cop-text-micro/nano/label/control/body/data/subheading/title` move from 7/8/9/10/11/12/13/14px to 9/10/11/12/13/14/15/16px; `--cop-text-panel` moves from 16px to 17px so it stays distinct from the new title size. - New `--cop-text-<step>--line-height` for every text step (Tailwind v4 reads these) and `--cop-text-<step>--tracking` for micro through title. - `--muted-foreground` lightens from `oklch(55% 0.01 240)` to `oklch(60% 0.01 240)`: 3.94:1 → 4.85:1 on the COP card surface.
v0.2.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.1patch

8c69bf4: Declare `--tome-type-sans/serif/mono/display: var(--font-*)` on the COP selector next to COP's font slots. tome-ui no longer re-derives the Layer-2 font families on a theme wrapper (so site font overrides survive inside other themes), so a theme that changes fonts must re-point them itself; without this, COP rendered inside a descendant wrapper would show the site's fonts instead of Inter / Playfair Display / Roboto Mono. Required by the new conformance font-pairing rule.

  • 8c69bf4: Declare `--tome-type-sans/serif/mono/display: var(--font-*)` on the COP selector next to COP's font slots. tome-ui no longer re-derives the Layer-2 font families on a theme wrapper (so site font overrides survive inside other themes), so a theme that changes fonts must re-point them itself; without this, COP rendered inside a descendant wrapper would show the site's fonts instead of Inter / Playfair Display / Roboto Mono. Required by the new conformance font-pairing rule.
v0.2.0minor

d5d72e1: **BREAKING:** COP activates on `data-tome-theme="cop"`, registers via `./register`, and needs `@wabbit/tome-ui` 0.14 or later. `data-tome-pack="cop"` still works as a deprecated alias. **Migration:** install `@wabbit/tome-ui` `>=0.14.0` (now a required peer) and, if you use the registry, `@wabbit/tome-core` `>=1.20.0`. Change `data-tome-pack="cop"` to `data-tome-theme="cop"` when convenient. Add `import '@wabbit/tome-cop/register'` where you build your Payload config so the admin theme switcher and `createThemeConfig()` offer COP. Replace `copPackManifest` with `copThemeManifest` and `withCopThemePack` with `createThemeConfig()`; both old names are deprecated and removed in a later minor. - Every scoped selector is now `:is([data-tome-theme="cop"], [data-tome-pack="cop"])`, which keeps each rule's specificity. The `--cop-*` values emitted at `:root` by `./tokens.css` are unchanged. - The hand-copied Layer-2 block (`tokens/layer2.css`) is removed; tome-ui now re-declares Layer 2 at every theme boundary. COP therefore also gains the chart and serif aliases the copy was missing: charts now use COP's own palette (new `--chart-1..5`), and `--font-serif` is set. - The feedback text weights moved to `tokens/literals.css`, which adds `--tome-color-accent-text` (it previously fell back to tome-ui's light-surface value on COP's dark panels). - The dark rule redeclares every token tome-ui's dark block declares, so dark mode no longer depends on stylesheet order. - `data-cop-effect` effects apply only on or inside a COP-themed element. - Per-block treatments are written in the descendant form, `[data-block-type="X"] [data-block-variant="Y"]`. With a `@wabbit/tome-blocks-core` that emits `data-block-type`, they now apply: callout icons and labels, spoiler and classification-banner stripes, the redacted-panel scanlines, sensor IFF dots, waveform bars, cross-link badges, comm-intercept status dots and system-alert severities. - New `copThemeManifest` (a `ThemeManifest`: kind `theme`, one `default` palette in light and dark, a Playfair Display + Inter + Roboto Mono pairing, `styledBlocks`, `legacySelectors`) and `COP_STYLED_BLOCKS`. The test suite runs the theme conformance check.

  • d5d72e1: **BREAKING:** COP activates on `data-tome-theme="cop"`, registers via `./register`, and needs `@wabbit/tome-ui` 0.14 or later. `data-tome-pack="cop"` still works as a deprecated alias. **Migration:** install `@wabbit/tome-ui` `>=0.14.0` (now a required peer) and, if you use the registry, `@wabbit/tome-core` `>=1.20.0`. Change `data-tome-pack="cop"` to `data-tome-theme="cop"` when convenient. Add `import '@wabbit/tome-cop/register'` where you build your Payload config so the admin theme switcher and `createThemeConfig()` offer COP. Replace `copPackManifest` with `copThemeManifest` and `withCopThemePack` with `createThemeConfig()`; both old names are deprecated and removed in a later minor. - Every scoped selector is now `:is([data-tome-theme="cop"], [data-tome-pack="cop"])`, which keeps each rule's specificity. The `--cop-*` values emitted at `:root` by `./tokens.css` are unchanged. - The hand-copied Layer-2 block (`tokens/layer2.css`) is removed; tome-ui now re-declares Layer 2 at every theme boundary. COP therefore also gains the chart and serif aliases the copy was missing: charts now use COP's own palette (new `--chart-1..5`), and `--font-serif` is set. - The feedback text weights moved to `tokens/literals.css`, which adds `--tome-color-accent-text` (it previously fell back to tome-ui's light-surface value on COP's dark panels). - The dark rule redeclares every token tome-ui's dark block declares, so dark mode no longer depends on stylesheet order. - `data-cop-effect` effects apply only on or inside a COP-themed element. - Per-block treatments are written in the descendant form, `[data-block-type="X"] [data-block-variant="Y"]`. With a `@wabbit/tome-blocks-core` that emits `data-block-type`, they now apply: callout icons and labels, spoiler and classification-banner stripes, the redacted-panel scanlines, sensor IFF dots, waveform bars, cross-link badges, comm-intercept status dots and system-alert severities. - New `copThemeManifest` (a `ThemeManifest`: kind `theme`, one `default` palette in light and dark, a Playfair Display + Inter + Roboto Mono pairing, `styledBlocks`, `legacySelectors`) and `COP_STYLED_BLOCKS`. The test suite runs the theme conformance check.
v0.1.9patch

0aa80a3: Drops the unused `@wabbit/tome-ui` peer dependency; nothing in the package imported it.

  • 0aa80a3: Drops the unused `@wabbit/tome-ui` peer dependency; nothing in the package imported it.
v0.1.8patch

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.

  • 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.
  • 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.
  • 73081e6: README peer tables, and the gate that now requires them. Sixteen packages declared `peerDependencies` and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's `sonner` peer, has its own changeset). Each of the sixteen gains a `## Peer dependencies` section generated from its own `package.json` — `| Peer | Range | Required |`, one row per peer, the range verbatim, `no (optional)` read from `peerDependenciesMeta`, plus one sentence on what is a real `dependency` rather than a peer and why the optional ones are optional. The worst omissions this surfaced: `@wabbit/tome-core` documented 2 of its 13 peers and left out both `next` and `@payloadcms/richtext-lexical`, which are required; `@wabbit/tome-admin` listed 5 of 20; `@wabbit/tome-readout` and `@wabbit/tome-sc` listed none. Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still `>=18` after the peer floor moved to `>=19.0.0` — and none of the eight listed `react-dom` at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them. The forcing function ships with the fix: `scripts/assert-readme-contract.mjs` now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing `Optional?` and `Notes` third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work. Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. `CONTRIBUTING.md`'s assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
v0.1.7patch

48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split. Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at **1.00–1.03:1** — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text. This is the second time this bug family has shipped. The first sweep added `--tome-color-on-solid-dark` (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed. This closes the rest and adds the tokens whose absence is why the misuse kept spreading. ## Three mechanisms, one root cause **1. Split pairs.** Blocks paired `--tome-color-card` — not a house token at all; the house name is `--tome-color-surface` — carrying a DARK literal fallback, against `--tome-color-foreground`, which every themed consumer does define, carrying a LIGHT one. A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (`surface`, the CARD family, paired with `foreground`, the PAGE family) and as clobbering: a band setting `color: background` on itself while its children hardcoded their own `color: foreground`, which wins. longform had a third variant — it read `--tome-color-muted-foreground` 32 times and `--tome-color-muted` 3 times, and **neither has ever been a house token**, so the entire muted tier silently fell through to `currentColor` and inherited whatever ink an ancestor happened to have. **2. Alpha-dimmed text.** `opacity: 0.4–0.9` on a label, and `color-mix(<colour> 30–70%, transparent)` as a `color:`, composite against whatever happens to be behind them, so the ratio is unknowable at author time. Worst measured: 1.46:1. Several were an ancestor `opacity` aimed at a rule that dimmed the real text in the same container along with it. **3. Status and brand colors used as text.** `warning`/`success`/`error`/ `destructive` are FILL colors, tuned to be painted as a badge with an ink on top. Used as `color:` the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: `--tome-color-primary` as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses. ## New in `@wabbit/tome-ui` - **`--tome-color-{success,warning,error,destructive,info}-text`** — the missing text-weight companions. Literals with inverted `[data-theme="dark"]` values, each pinned to clear 4.5:1 against both `--tome-color-background` and `--tome-color-surface`. `info` never had a fill token either, which is why packs reached for `primary`. Rule of thumb: `warning` paints a box, `warning-text` writes a word. - **`--muted-foreground` retuned** (`hsl(215 16% 47%)` → `hsl(215 20% 38%)` light, `65%` → `72%` dark). That token is `--tome-color-on-surface-muted`, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on `--card`. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only. Because these are literals rather than Layer 1 aliases, `@wabbit/tome-cop` restates them: a pack that is dark without being `[data-theme="dark"]` would otherwise inherit the light values. ## Fix shape, per pack **dispatch, readout, blocks-signal-theme** are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (`--dispatch-*`, `--readout-*`, `--signal-*`) with internally consistent dark defaults compiled into every block module as inline fallbacks. No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house `surface-solid-dark`/`on-solid-dark` pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article. **longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras** follow the ambient theme and are fixed with the house vocabulary: correct pairs (`surface`/`on-surface`, `background`/ `foreground`, `primary`/`on-primary`), the new `-text` weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local `--_on-band`, and brand/status text reads `var(--_on-band, <its normal
v0.1.6patch

71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

  • 71d3b09: Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed). - **dispatch**: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents). - **readout**: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored. - **blocks-signal-theme**: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged. - **blocks-extras / blocks-content-writer**: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized. - **blocks-core**: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized. - **blocks-gallery**: SourceBadge label for the `vngd` source value now renders "Legacy" (enum value unchanged). - **accounts / core / lms / ui / org / admin / motion / longform / cop / blocks**: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
v0.1.5patch

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: 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.
v0.1.4patch

ec4b7bc: T3 token convergence: `--cop-*` namespace now also emitted at `:root` (collision-safe; lets sites consume the design-system values without activating the pack re-theme), new `./tokens.css` subpath export (raw tokens only, no layer1/layer2), Layer-2 re-emissions extended beyond color (`--tome-radius` pass-through + `--tome-type-sans/mono/display` font routing, live on tome-ui >=0.9.8), README peer-range drift fixed (>=0.9.0 <1.0.0).

  • ec4b7bc: T3 token convergence: `--cop-*` namespace now also emitted at `:root` (collision-safe; lets sites consume the design-system values without activating the pack re-theme), new `./tokens.css` subpath export (raw tokens only, no layer1/layer2), Layer-2 re-emissions extended beyond color (`--tome-radius` pass-through + `--tome-type-sans/mono/display` font routing, live on tome-ui >=0.9.8), README peer-range drift fixed (>=0.9.0 <1.0.0).
v0.1.3patch

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.

Planning

v0.2.0
v0.2.0minor

e2f64c4: The registry now lists and writes the American keys `ground-armor`, `m1eq-armored` and the preset id `armored-patrol`. The old keys `ground-armour`, `m1eq-armoured` and `armoured-patrol` swap places and become the non-enumerable aliases, so a stored mark still draws the same and a picker offers only the new ones. **Update your code first:** `MAP_SYMBOL_KEYS`, `Object.keys(MAP_SYMBOLS)`, `SYMBOL_PRESETS` and the alias tables now hold the new keys. Code that compares a stored key to a listed one should resolve it through `MAP_SYMBOLS`, `MAP_MODIFIERS` or `getSymbolPreset` until the stored keys are backfilled.

  • e2f64c4: The registry now lists and writes the American keys `ground-armor`, `m1eq-armored` and the preset id `armored-patrol`. The old keys `ground-armour`, `m1eq-armoured` and `armoured-patrol` swap places and become the non-enumerable aliases, so a stored mark still draws the same and a picker offers only the new ones. **Update your code first:** `MAP_SYMBOL_KEYS`, `Object.keys(MAP_SYMBOLS)`, `SYMBOL_PRESETS` and the alias tables now hold the new keys. Code that compares a stored key to a listed one should resolve it through `MAP_SYMBOLS`, `MAP_MODIFIERS` or `getSymbolPreset` until the stored keys are backfilled.
v0.1.3patch

76238e9: The symbol keys `ground-armor` and `m1eq-armored` and the preset id `armored-patrol` now resolve to the same entries as `ground-armour`, `m1eq-armoured` and `armoured-patrol`. This is the first of three steps, and nothing that gets written changes yet. The new keys are non-enumerable aliases, so pickers, `MAP_SYMBOL_KEYS` and the category counts still list and write the old keys. Look keys up on the registry itself (`MAP_SYMBOLS[key]`); a spread or serialized copy drops the aliases. `MAP_SYMBOL_KEY_ALIASES`, `MAP_MODIFIER_KEY_ALIASES` and `SYMBOL_PRESET_ID_ALIASES` are exported.

  • 76238e9: The symbol keys `ground-armor` and `m1eq-armored` and the preset id `armored-patrol` now resolve to the same entries as `ground-armour`, `m1eq-armoured` and `armoured-patrol`. This is the first of three steps, and nothing that gets written changes yet. The new keys are non-enumerable aliases, so pickers, `MAP_SYMBOL_KEYS` and the category counts still list and write the old keys. Look keys up on the registry itself (`MAP_SYMBOLS[key]`); a spread or serialized copy drops the aliases. `MAP_SYMBOL_KEY_ALIASES`, `MAP_MODIFIER_KEY_ALIASES` and `SYMBOL_PRESET_ID_ALIASES` are exported.
v0.1.2patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.1patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.1.0minor

5f84426: Initial release — MIL-STD-2525 / APP-6 symbology for Tome sites: a pre-rendered symbol registry, modifier fragments, curated presets, a fixed reference grid, collision-aware label placement, and server-side symbol composition. - **Root (`@wabbit/tome-planning`), client-safe:** `MAP_SYMBOLS` (151 pre-rendered icons) and `MAP_MODIFIERS` (41 modifier fragments), generated at build time by `scripts/generate-symbols.cjs`; category and symbol-set metadata with the set/slot rule a picker must obey (`MAP_SYMBOL_CATEGORIES`, `MAP_SYMBOL_SETS`, `symbolKeysByCategory`, `symbolCountsByCategory`, `modifierKeysFor`); nine presets (`SYMBOL_PRESETS`, `getSymbolPreset`, `matchPreset`); the shared `ThreatLevel` and `SymbolSpec` types. - **`./grid`:** a fixed 8×8 reference grid over a normalized 0–1 drawing surface, giving spreadsheet-style cell references (`C4`). - **`./labels`:** collision-aware label placement. - **`./compose`, server-only:** `composeSymbol`, the one module that loads `milsymbol` (it opens with `import 'server-only'`), so the renderer never reaches a client bundle. `sideEffects` names the compose artifacts so bundlers keep that guard. - No React runtime, Payload collection or access model ships in this release; those stay in the consuming app.

  • 5f84426: Initial release — MIL-STD-2525 / APP-6 symbology for Tome sites: a pre-rendered symbol registry, modifier fragments, curated presets, a fixed reference grid, collision-aware label placement, and server-side symbol composition. - **Root (`@wabbit/tome-planning`), client-safe:** `MAP_SYMBOLS` (151 pre-rendered icons) and `MAP_MODIFIERS` (41 modifier fragments), generated at build time by `scripts/generate-symbols.cjs`; category and symbol-set metadata with the set/slot rule a picker must obey (`MAP_SYMBOL_CATEGORIES`, `MAP_SYMBOL_SETS`, `symbolKeysByCategory`, `symbolCountsByCategory`, `modifierKeysFor`); nine presets (`SYMBOL_PRESETS`, `getSymbolPreset`, `matchPreset`); the shared `ThreatLevel` and `SymbolSpec` types. - **`./grid`:** a fixed 8×8 reference grid over a normalized 0–1 drawing surface, giving spreadsheet-style cell references (`C4`). - **`./labels`:** collision-aware label placement. - **`./compose`, server-only:** `composeSymbol`, the one module that loads `milsymbol` (it opens with `import 'server-only'`), so the renderer never reaches a client bundle. `sideEffects` names the compose artifacts so bundlers keep that guard. - No React runtime, Payload collection or access model ships in this release; those stay in the consuming app.

Webgl

v0.8.4
v0.8.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.8.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.8.2patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.8.1patch

d5a7661: `styles.css` now ships in `dist/` and is exported as `@wabbit/tome-webgl/styles.css`; it was previously never copied by the build, so consumers importing the stylesheet got nothing.

  • d5a7661: `styles.css` now ships in `dist/` and is exported as `@wabbit/tome-webgl/styles.css`; it was previously never copied by the build, so consumers importing the stylesheet got nothing.
v0.8.0minor

1f7c08d: `@wabbit/tome-blocks-core` moves from `dependencies` to an **optional peer** (`>=0.16.6 <1.0.0`); it stays a workspace devDependency. webgl is a domain-layer package and uses blocks-core for a single `import type` in `./block`, so the hard edge installed a blocks-layer package on every consumer for an import that is erased at build time (2026-09-24 audit A3 §3 / #10, now gated by `assert:declared-imports`). Sites that use `./block` are Payload + blocks sites and already have blocks-core. A site that uses only the R3F runtime no longer installs it.

  • 1f7c08d: `@wabbit/tome-blocks-core` moves from `dependencies` to an **optional peer** (`>=0.16.6 <1.0.0`); it stays a workspace devDependency. webgl is a domain-layer package and uses blocks-core for a single `import type` in `./block`, so the hard edge installed a blocks-layer package on every consumer for an import that is erased at build time (2026-09-24 audit A3 §3 / #10, now gated by `assert:declared-imports`). Sites that use `./block` are Payload + blocks sites and already have blocks-core. A site that uses only the R3F runtime no longer installs it.
v0.7.1patch

Updated dependencies [404d325] - @wabbit/tome-blocks-core@0.17.0

  • Updated dependencies [404d325] - @wabbit/tome-blocks-core@0.17.0
v0.7.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • 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.
  • 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.
  • Updated dependencies [57875ba]
  • Updated dependencies [b01ca1f]
  • Updated dependencies [0836ef5]
  • Updated dependencies [73081e6]
  • Updated dependencies [090e984]
  • Updated dependencies [73081e6] - @wabbit/tome-blocks-core@0.16.0
v0.6.13patch

Alignment republish: these two artifacts were the last on the registry published before the workspace:^ policy, carrying exact @wabbit dependency pins (blocks-core 0.15.0, blocks-extras 0.15.0) that force nested duplicate copies — and split blocks-core's renderer/link registries — in any consumer whose tree moves past 0.15.0. No source changes; the republish ships range deps.

  • Alignment republish: these two artifacts were the last on the registry published before the workspace:^ policy, carrying exact @wabbit dependency pins (blocks-core 0.15.0, blocks-extras 0.15.0) that force nested duplicate copies — and split blocks-core's renderer/link registries — in any consumer whose tree moves past 0.15.0. No source changes; the republish ships range deps.
v0.6.12patch

Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0

  • Updated dependencies [510036f] - @wabbit/tome-blocks-core@0.15.0
v0.6.11patch

Updated dependencies - @wabbit/tome-blocks-core@0.14.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.14.0
v0.6.10patch

Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0

  • Updated dependencies [f4d55c9] - @wabbit/tome-blocks-core@0.13.0
v0.6.9patch

Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2

  • Updated dependencies [e11d5a2] - @wabbit/tome-blocks-core@0.11.2
v0.6.8patch

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: 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.
  • aef2725: Monolith decompositions (behavior- and markup-preserving; public APIs unchanged; markup identity mechanically verified per file): forms' FieldRenderer 633→84 via a field-control registry + shared FieldChrome (consent/checkbox byte-identical branches merged) and TomeForm 656→451 via four extracted hooks (the ordering-critical resolver sync deliberately stays inline, documented); rpg's CharacterSheet 841→130 across panels + three editing hooks + persistence hook (the StrictMode XP-ledger charRef guard preserved verbatim); gallery's GalleryIndex 1032→431 (BlockThumb/BlockCard/Toolbar/useFilteredCatalog siblings, T2's debounce+memo preserved); webgl's WebglCanvasProvider 938→546 (useTransitionOrchestrator + useCanvasRenderer extracted; settle thresholds hoisted to named consts); admin's mergeAdminComponents 828→404 orchestrator + four helpers (all docblocks relocated, 717 tests unmodified) and Nav's config-reading now typed (6 of 8 `as any` casts eliminated); marketing-starter's PricingPlans extracts its GSAP toggle timeline hook + a memoized card. rpg additionally trusts the denormalized `xpTotal` on sheet load/save hot paths (full recompute stays at the XP-recording reconciliation point).
  • Updated dependencies [26dfa07]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [36e537a]
  • Updated dependencies [a93f478]
  • Updated dependencies [5f78397]
  • Updated dependencies [5f78397]
  • Updated dependencies [aef2725] - @wabbit/tome-blocks-core@0.11.0
v0.6.7patch

Updated dependencies - @wabbit/tome-blocks-core@0.10.0

  • Updated dependencies - @wabbit/tome-blocks-core@0.10.0
v0.6.6patch

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.
  • Updated dependencies [bed3f90] - @wabbit/tome-blocks-core@0.9.4
v0.6.5patch

Updated dependencies - @wabbit/tome-blocks-core@0.9.2

  • Updated dependencies - @wabbit/tome-blocks-core@0.9.2
v0.6.4patch

Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0

  • Updated dependencies [c7d0afc] - @wabbit/tome-blocks-core@0.9.0
v0.6.3patch

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

  • Updated dependencies [249b670] - @wabbit/tome-blocks-core@0.8.0
v0.6.2patch

Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0

  • Updated dependencies [66c611c] - @wabbit/tome-blocks-core@0.7.0
v0.6.1patch

Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2

  • Updated dependencies [4b2f368] - @wabbit/tome-blocks-core@0.6.2
v0.6.0minor

feat(webgl): renderer robustness + TSL/WebGPU hardening (0.6.0) Hardens the node-renderer path introduced for TSL and proves TSL↔GLSL parity. Additive and non-breaking — `classic` + GLSL stays the default; existing consumers are unaffected. - **WebGPU adapter init hardening** — `node-webgpu` now races `renderer.init()` against a timeout and, on adapter-request hang/rejection, downgrades to a WebGL2 (`forceWebGL:true`) renderer with a one-time warn instead of hanging the canvas. `node-webgl` stays deterministic. - **postprocessing hard-gate** — `policy.postprocessing` under a node renderer now throws an actionable error (`@react-three/postprocessing` is WebGL-bound and crashes on `WebGPURenderer`). The flag was previously inert, so no consumer breaks. Native node-renderer postprocessing via three's `RenderPipeline` is tracked for 0.7.x. - **renderer-init fallback** — a new `RendererInitBoundary` renders a static poster if all renderer paths fail, instead of a blank/crashed canvas. - **docs** — a README with the renderer decision tree (`classic`/`node-webgl`/`node-webgpu`), a TSL transition authoring guide, and a `@react-three/drei` compatibility matrix under node renderers (`<Environment>`/`<Sky>`/reflectors break; controls/`useProgress`/`useGLTF`/`Html` are safe). `node-webgpu` remains "validate `node-webgl` in production first." TSL↔GLSL render parity for the built-in spiral was verified pixel-identical on the WebGL2/`node-webgl` path (render-parity harness in `test/parity/`).

  • feat(webgl): renderer robustness + TSL/WebGPU hardening (0.6.0) Hardens the node-renderer path introduced for TSL and proves TSL↔GLSL parity. Additive and non-breaking — `classic` + GLSL stays the default; existing consumers are unaffected. - **WebGPU adapter init hardening** — `node-webgpu` now races `renderer.init()` against a timeout and, on adapter-request hang/rejection, downgrades to a WebGL2 (`forceWebGL:true`) renderer with a one-time warn instead of hanging the canvas. `node-webgl` stays deterministic. - **postprocessing hard-gate** — `policy.postprocessing` under a node renderer now throws an actionable error (`@react-three/postprocessing` is WebGL-bound and crashes on `WebGPURenderer`). The flag was previously inert, so no consumer breaks. Native node-renderer postprocessing via three's `RenderPipeline` is tracked for 0.7.x. - **renderer-init fallback** — a new `RendererInitBoundary` renders a static poster if all renderer paths fail, instead of a blank/crashed canvas. - **docs** — a README with the renderer decision tree (`classic`/`node-webgl`/`node-webgpu`), a TSL transition authoring guide, and a `@react-three/drei` compatibility matrix under node renderers (`<Environment>`/`<Sky>`/reflectors break; controls/`useProgress`/`useGLTF`/`Html` are safe). `node-webgpu` remains "validate `node-webgl` in production first." TSL↔GLSL render parity for the built-in spiral was verified pixel-identical on the WebGL2/`node-webgl` path (render-parity harness in `test/parity/`).
v0.5.0minor

feat(webgl): TSL-variant mode (dual-mode transitions + `./tsl` subpath) Additive, non-breaking TSL lane alongside the GLSL/`classic` default. Existing consumers are unaffected until they opt in. - **`./transitions`** — `TransitionDefinition` becomes a discriminated union: the GLSL `fragmentShader` arm is source-compatible with every existing definition, plus a new TSL `nodeShader: (ctx: TransitionNodeContext) => Node` arm. Adds the `TransitionNodeContext` / `TransitionNodeBuilder` contract. - **`./registry`** — `WebglCanvasPolicy` gains `autoUpgradeRendererForTsl`; `node-webgpu` promoted from "reserved" to supported (validate `node-webgl` in production first). - **runtime** — `TransitionCompositor` branches on the resolved definition: GLSL builds `THREE.ShaderMaterial` unchanged; TSL lazily imports `three/webgpu` and builds a `NodeMaterial` `colorNode`. `WebglCanvasProvider` enforces the renderer auto-upgrade contract — a TSL transition under `renderer: 'classic'` throws an actionable error by default, or coerces to `node-webgl` (with a one-time warn) when `autoUpgradeRendererForTsl` is set. Never a silent no-op or GLSL fallback. - **`./tsl`** (new subpath) — React-free TSL `Fn` helper library (`curlNoise`/`snoiseVec3`, `voronoi3D`, `scaffoldFloat`, `wrappedDiffuseTwoLight`/`blendVolumeNormal`/`fresnelRim`/`pulseEnvelope`/`pulseWavefront`) plus `builtInTransitionsTSL` node-builders for the six curtains. Imports `three/tsl` only; never touches React. - **peer** — `three` floor raised `>=0.171` → `>=0.180` (the floor that exports the required TSL/MaterialX surface). `three/webgpu` + `three/tsl` are dynamic-import-only, so the `classic` GLSL path never pulls the WebGPU bundle.

  • feat(webgl): TSL-variant mode (dual-mode transitions + `./tsl` subpath) Additive, non-breaking TSL lane alongside the GLSL/`classic` default. Existing consumers are unaffected until they opt in. - **`./transitions`** — `TransitionDefinition` becomes a discriminated union: the GLSL `fragmentShader` arm is source-compatible with every existing definition, plus a new TSL `nodeShader: (ctx: TransitionNodeContext) => Node` arm. Adds the `TransitionNodeContext` / `TransitionNodeBuilder` contract. - **`./registry`** — `WebglCanvasPolicy` gains `autoUpgradeRendererForTsl`; `node-webgpu` promoted from "reserved" to supported (validate `node-webgl` in production first). - **runtime** — `TransitionCompositor` branches on the resolved definition: GLSL builds `THREE.ShaderMaterial` unchanged; TSL lazily imports `three/webgpu` and builds a `NodeMaterial` `colorNode`. `WebglCanvasProvider` enforces the renderer auto-upgrade contract — a TSL transition under `renderer: 'classic'` throws an actionable error by default, or coerces to `node-webgl` (with a one-time warn) when `autoUpgradeRendererForTsl` is set. Never a silent no-op or GLSL fallback. - **`./tsl`** (new subpath) — React-free TSL `Fn` helper library (`curlNoise`/`snoiseVec3`, `voronoi3D`, `scaffoldFloat`, `wrappedDiffuseTwoLight`/`blendVolumeNormal`/`fresnelRim`/`pulseEnvelope`/`pulseWavefront`) plus `builtInTransitionsTSL` node-builders for the six curtains. Imports `three/tsl` only; never touches React. - **peer** — `three` floor raised `>=0.171` → `>=0.180` (the floor that exports the required TSL/MaterialX surface). `three/webgpu` + `three/tsl` are dynamic-import-only, so the `classic` GLSL path never pulls the WebGPU bundle.
v0.4.1patch

8f58d28: fix(webgl): hoist node-renderer `useMemo` above early-return in `WebglBackgroundCanvas` 0.4.0 placed the new `glFactory` `useMemo` below the existing `if (!activeBackground && !isTransitioning) return null` guard. When the canvas mounted empty (no active scene yet) and then a scene arrived, hook count went 4 → 5 and React killed the tree with `Rendered more hooks than during the previous render` — observed on a consumer site's first-paint with `policy={{ renderer: 'node-webgl' }}`. Fix: move the `useMemo` above the early return so the hook order is invariant across both paths. No behavior change — the factory was already memoized on `policy.renderer`. Inline comment added to the call site so the next contributor doesn't reintroduce the regression.

  • 8f58d28: fix(webgl): hoist node-renderer `useMemo` above early-return in `WebglBackgroundCanvas` 0.4.0 placed the new `glFactory` `useMemo` below the existing `if (!activeBackground && !isTransitioning) return null` guard. When the canvas mounted empty (no active scene yet) and then a scene arrived, hook count went 4 → 5 and React killed the tree with `Rendered more hooks than during the previous render` — observed on a consumer site's first-paint with `policy={{ renderer: 'node-webgl' }}`. Fix: move the `useMemo` above the early return so the hook order is invariant across both paths. No behavior change — the factory was already memoized on `policy.renderer`. Inline comment added to the call site so the next contributor doesn't reintroduce the regression.
v0.4.0minor

7b07215: feat(webgl): opt-in node-aware renderer via `policy.renderer` `WebglCanvasPolicy` gains a `renderer` field. Default `'classic'` preserves current behavior (R3F default `THREE.WebGLRenderer`). New `'node-webgl'` opts the shared background canvas into `THREE.WebGPURenderer` with `forceWebGL: true` — required for TSL / `NodeMaterial` consumers. Hardware path stays WebGL2, so browser support is unchanged. A future `'node-webgpu'` value is reserved for 0.5.x once adapter-request timing + WebGPU detection bootstrap are handled in the provider. The WebGPU bundle (`three/webgpu`) is dynamically imported inside the gl factory, so consumers on `'classic'` pay no bundle cost. Inline experiences (`WebglInlineBlock`) bring their own `<Canvas>` and pick their own renderer — this change only affects the shared background canvas. Rationale: consumer-side TSL migrations (e.g. a consumer's node-aware rendering work) were blocked because `THREE.WebGLRenderer` has no node-aware path. Three's only node-aware renderer is `WebGPURenderer`, which transparently runs on either WebGPU or WebGL2 backends. Shipping this opt-in unblocks consumer TSL work without forcing the change on consumers that aren't ready.

  • 7b07215: feat(webgl): opt-in node-aware renderer via `policy.renderer` `WebglCanvasPolicy` gains a `renderer` field. Default `'classic'` preserves current behavior (R3F default `THREE.WebGLRenderer`). New `'node-webgl'` opts the shared background canvas into `THREE.WebGPURenderer` with `forceWebGL: true` — required for TSL / `NodeMaterial` consumers. Hardware path stays WebGL2, so browser support is unchanged. A future `'node-webgpu'` value is reserved for 0.5.x once adapter-request timing + WebGPU detection bootstrap are handled in the provider. The WebGPU bundle (`three/webgpu`) is dynamically imported inside the gl factory, so consumers on `'classic'` pay no bundle cost. Inline experiences (`WebglInlineBlock`) bring their own `<Canvas>` and pick their own renderer — this change only affects the shared background canvas. Rationale: consumer-side TSL migrations (e.g. a consumer's node-aware rendering work) were blocked because `THREE.WebGLRenderer` has no node-aware path. Three's only node-aware renderer is `WebGPURenderer`, which transparently runs on either WebGPU or WebGL2 backends. Shipping this opt-in unblocks consumer TSL work without forcing the change on consumers that aren't ready.
v0.3.2patch

@wabbit/tome-blocks-core@0.5.9

  • @wabbit/tome-blocks-core@0.5.9
v0.3.1patch

fab6fac: Opaque-curtain transition (replaces the FBO cross-blend that produced squashed spirals + camera jumps + no load-gate flash). Single live scene, hidden swap at full curtain cover, load-gated hold (Suspense done + drei `useProgress` idle + camera-idle via `SettleWatch`), reveal-on-direct-load (no outgoing → start at full cover, hold for load, uncover). Provider gains `pendingTargetRef` + `commitSwap` + `markIncomingReady` plumbing with an idempotency guard against legacy-store re-fires. Shaders rewritten to `uColor` + alpha (no scene textures); spiral now aspect-corrected. `TransitionDefinition` shape unchanged; `TransitionCompositorProps` adds `color`, `readyRef`, `revealOnly`. 34 tests green.

  • fab6fac: Opaque-curtain transition (replaces the FBO cross-blend that produced squashed spirals + camera jumps + no load-gate flash). Single live scene, hidden swap at full curtain cover, load-gated hold (Suspense done + drei `useProgress` idle + camera-idle via `SettleWatch`), reveal-on-direct-load (no outgoing → start at full cover, hold for load, uncover). Provider gains `pendingTargetRef` + `commitSwap` + `markIncomingReady` plumbing with an idempotency guard against legacy-store re-fires. Shaders rewritten to `uColor` + alpha (no scene textures); spiral now aspect-corrected. `TransitionDefinition` shape unchanged; `TransitionCompositorProps` adds `color`, `readyRef`, `revealOnly`. 34 tests green.
v0.3.0minor

a03fbdc: Add `useTransitionPhase()` — the hybrid choreography channel for background experiences. Pure FBO (0.2.0) cross-blends two static scene images. The hybrid model also lets each background scene choreograph its OWN entrance/exit _while_ it is being composited. During a transition the `TransitionCompositor` now publishes the live eased progress plus each scene's role (`'from'` outgoing / `'to'` incoming) through a React context; a scene reads it via `useTransitionPhase()` inside `useFrame` (a ref, never a render value — no per-frame React re-render) and animates its objects, while the transition shader blends the two animating images. - New export `useTransitionPhase()` returning `{ role: 'from' | 'to' | null, progress: MutableRefObject<number> }`. At rest: `{ role: null, progress.current === 1 }`. - New exported types `TransitionPhase`, `TransitionRole`. - Backward-compatible: a scene that ignores the hook renders static (pure-FBO behavior, unchanged from 0.2.0). The `BackgroundExperienceProps.transitionProgress` prop stays the coarse signal; the hook is the live channel. The choreography itself is consumer-owned (per-scene entrance/exit + which transitions per route are author decisions); the platform supplies the channel. Also in this release: - **Per-scene camera in the FBO compositor** — background experiences mount their own camera (e.g. drei `<PerspectiveCamera makeDefault>`); the compositor now renders each sub-scene to its target with ITS OWN camera (falls back to the main camera), so a scene's intended framing is preserved during a transition. - **Interactivity mode (mixed-by-route)** — new `interactive` flag + `setInteractive()` on the canvas context, a `useCanvasInteractive()` hook, and the background canvas now toggles `pointer-events` accordingly. A consumer's route director can make the global canvas interactive on showcase routes (controls + pointer events) and a passive backdrop elsewhere. Default false (passive) — unchanged from prior behavior.

  • a03fbdc: Add `useTransitionPhase()` — the hybrid choreography channel for background experiences. Pure FBO (0.2.0) cross-blends two static scene images. The hybrid model also lets each background scene choreograph its OWN entrance/exit _while_ it is being composited. During a transition the `TransitionCompositor` now publishes the live eased progress plus each scene's role (`'from'` outgoing / `'to'` incoming) through a React context; a scene reads it via `useTransitionPhase()` inside `useFrame` (a ref, never a render value — no per-frame React re-render) and animates its objects, while the transition shader blends the two animating images. - New export `useTransitionPhase()` returning `{ role: 'from' | 'to' | null, progress: MutableRefObject<number> }`. At rest: `{ role: null, progress.current === 1 }`. - New exported types `TransitionPhase`, `TransitionRole`. - Backward-compatible: a scene that ignores the hook renders static (pure-FBO behavior, unchanged from 0.2.0). The `BackgroundExperienceProps.transitionProgress` prop stays the coarse signal; the hook is the live channel. The choreography itself is consumer-owned (per-scene entrance/exit + which transitions per route are author decisions); the platform supplies the channel. Also in this release: - **Per-scene camera in the FBO compositor** — background experiences mount their own camera (e.g. drei `<PerspectiveCamera makeDefault>`); the compositor now renders each sub-scene to its target with ITS OWN camera (falls back to the main camera), so a scene's intended framing is preserved during a transition. - **Interactivity mode (mixed-by-route)** — new `interactive` flag + `setInteractive()` on the canvas context, a `useCanvasInteractive()` hook, and the background canvas now toggles `pointer-events` accordingly. A consumer's route director can make the global canvas interactive on showcase routes (controls + pointer events) and a passive backdrop elsewhere. Default false (passive) — unchanged from prior behavior.
v0.2.0minor

3ab7144: `@wabbit/tome-webgl/transitions` ships its first real render-to-target (FBO) implementation — the module is no longer a documented stub. What changed (see spec amendment A1, 2026-05-25): - **`registerTransition`** now writes to a module-level registry that the compositor resolves against (was a no-op `void definition`). Built-ins seed it; consumer registrations and per-controller `transitions` config merge over them. - **`createTransitionController`** is real: `trigger(toKey, name?)` routes a scene change through the mounted provider's FBO compositor and resolves when the transition completes (or is skipped — first scene / reduced motion / no canvas mounted). `list()` returns built-in ∪ registered ∪ config. - **`TransitionCompositor`** (new, internal to the runtime) renders the outgoing and incoming background scenes to two render targets via `createPortal`, then composites them with the active transition's fragment shader (`uFrom`/`uTo`/`uProgress`) on a clip-space full-screen quad. It mounts only during the transition window; steady state renders the single active scene directly, so the dual-render cost is bounded. - **`transitionProgress`** is frame-driven: the smooth `0→1` value lives in a ref advanced in `useFrame` and written straight to the shader uniform (no per-frame React re-render). The `BackgroundExperienceProps.transitionProgress` prop contract is preserved but coarse (`0` at start, `1` at completion). - **`singleLiveCanvas`** is now enforced (was stored-never-read): the background canvas pauses (`frameloop='never'`) when the document is hidden or no scene is active, and a new `claimLiveCanvas`/`releaseLiveCanvas` API on the provider lets an in-view inline canvas pause the background. `WebglInlineBlock` claims it automatically when mounted under a provider (graceful no-op without one). - **Reduced motion:** when `respectReducedMotion` is on and the user prefers reduced motion, transitions are skipped (the new scene swaps in directly). - New export: `useOptionalWebglCanvasContext` (non-throwing context read). New provider prop: `transition?: { enabled?, default?, durationMs? }`. Admin-configurable transitions (per-page + per-route, editor-controlled): - **`createTransitionField(opts?)`** (`@wabbit/tome-webgl/block`, React-free) — a Payload `select` field pre-populated with the built-in transition names (+ any `extra` custom names), with an `Auto` option. Registry-injection pattern like `experienceKey`. Drop it on a Page collection (per-page override) or inside a global route-map array. - **`resolveTransition({ pageTransition, routeMap, toPath, directional, fallback })`** + **`matchRouteTransition`** (`@wabbit/tome-webgl/transitions`, pure) — resolve a transition with precedence **per-page override → global route map → directional default → fallback** (`'auto'`/null = unset; route map supports exact + `prefix/*` longest-wildcard match). The directional default is consumer-supplied (sibling order / route depth is consumer route data), keeping the layer generic. - New React-free constants `BUILT_IN_TRANSITION_NAMES` / `AUTO_TRANSITION` (`@wabbit/tome-webgl/transitions`). Non-breaking: the v0 inline-block + background contract is unchanged; `setActiveBackground(key)` keeps its signature (now optionally animates). The FBO composite's visual correctness is gated at the proving consumer (a consumer site + Playwright), per amendment A1 — WebGL cannot render in node, so the in-package gate covers the pure logic (registry, controller, easing, progress math).

  • 3ab7144: `@wabbit/tome-webgl/transitions` ships its first real render-to-target (FBO) implementation — the module is no longer a documented stub. What changed (see spec amendment A1, 2026-05-25): - **`registerTransition`** now writes to a module-level registry that the compositor resolves against (was a no-op `void definition`). Built-ins seed it; consumer registrations and per-controller `transitions` config merge over them. - **`createTransitionController`** is real: `trigger(toKey, name?)` routes a scene change through the mounted provider's FBO compositor and resolves when the transition completes (or is skipped — first scene / reduced motion / no canvas mounted). `list()` returns built-in ∪ registered ∪ config. - **`TransitionCompositor`** (new, internal to the runtime) renders the outgoing and incoming background scenes to two render targets via `createPortal`, then composites them with the active transition's fragment shader (`uFrom`/`uTo`/`uProgress`) on a clip-space full-screen quad. It mounts only during the transition window; steady state renders the single active scene directly, so the dual-render cost is bounded. - **`transitionProgress`** is frame-driven: the smooth `0→1` value lives in a ref advanced in `useFrame` and written straight to the shader uniform (no per-frame React re-render). The `BackgroundExperienceProps.transitionProgress` prop contract is preserved but coarse (`0` at start, `1` at completion). - **`singleLiveCanvas`** is now enforced (was stored-never-read): the background canvas pauses (`frameloop='never'`) when the document is hidden or no scene is active, and a new `claimLiveCanvas`/`releaseLiveCanvas` API on the provider lets an in-view inline canvas pause the background. `WebglInlineBlock` claims it automatically when mounted under a provider (graceful no-op without one). - **Reduced motion:** when `respectReducedMotion` is on and the user prefers reduced motion, transitions are skipped (the new scene swaps in directly). - New export: `useOptionalWebglCanvasContext` (non-throwing context read). New provider prop: `transition?: { enabled?, default?, durationMs? }`. Admin-configurable transitions (per-page + per-route, editor-controlled): - **`createTransitionField(opts?)`** (`@wabbit/tome-webgl/block`, React-free) — a Payload `select` field pre-populated with the built-in transition names (+ any `extra` custom names), with an `Auto` option. Registry-injection pattern like `experienceKey`. Drop it on a Page collection (per-page override) or inside a global route-map array. - **`resolveTransition({ pageTransition, routeMap, toPath, directional, fallback })`** + **`matchRouteTransition`** (`@wabbit/tome-webgl/transitions`, pure) — resolve a transition with precedence **per-page override → global route map → directional default → fallback** (`'auto'`/null = unset; route map supports exact + `prefix/*` longest-wildcard match). The directional default is consumer-supplied (sibling order / route depth is consumer route data), keeping the layer generic. - New React-free constants `BUILT_IN_TRANSITION_NAMES` / `AUTO_TRANSITION` (`@wabbit/tome-webgl/transitions`). Non-breaking: the v0 inline-block + background contract is unchanged; `setActiveBackground(key)` keeps its signature (now optionally animates). The FBO composite's visual correctness is gated at the proving consumer (a consumer site + Playwright), per amendment A1 — WebGL cannot render in node, so the in-package gate covers the pure logic (registry, controller, easing, progress math).
  • @wabbit/tome-blocks-core@0.5.7

Engine

v0.2.5
v0.2.5patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.2.4patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.3patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.2.2patch

e3c58e0: README: the quickstart no longer claims to mirror the starter's old `OrbitScene` showcase (replaced by the scroll-story page); it now points at the starter's playable flight chapter (`RingRunScene.ts`, `EngineRun.tsx`) as the fuller example.

  • e3c58e0: README: the quickstart no longer claims to mirror the starter's old `OrbitScene` showcase (replaced by the scroll-story page); it now points at the starter's playable flight chapter (`RingRunScene.ts`, `EngineRun.tsx`) as the fuller example.
v0.2.1patch

68a6e0c: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata. The HOSAS slots in `defaultProfile()` and `virpilHosasProfile()` now use placeholder device IDs instead of two specific stick models' IDs; real devices register at runtime and are bound from the bindings screen. `virpilHosasProfile()` is now labeled "Dual-stick HOSAS".

  • 68a6e0c: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata. The HOSAS slots in `defaultProfile()` and `virpilHosasProfile()` now use placeholder device IDs instead of two specific stick models' IDs; real devices register at runtime and are bound from the bindings screen. `virpilHosasProfile()` is now labeled "Dual-stick HOSAS".
v0.2.0minor

b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.

  • b01ca1f: Raise the `react` / `react-dom` peer floor to `>=19.0.0` (ruled 2026-09-01). The platform declared React peers in five different shapes — `>=18.0.0`, `>=18`, `^18 || ^19`, `^18.3.0 || ^19.0.0`, `^19.0.0` — while its kernel (`@wabbit/tome-core`) and five app-layer packages already required `>=19`. Any package advertising React 18 was advertising a configuration that could not be installed alongside the kernel, so the split was never a supported matrix; it was drift. One shape now, and it is the honest one. These nine version independently of the `linked` blocks family (which gets its own coordinated bump), so they are listed here: - `@wabbit/tome-admin`, `@wabbit/tome-admin-pro` — from `^18.3.0 || ^19.0.0` - `@wabbit/tome-blocks-gallery` — from `^18 || ^19`; devDeps `react`/`@types/react` `^18.0.0` → `^19.0.0` - `@wabbit/tome-blocks-org-pack` — from `>=18.0.0`; same devDep correction - `@wabbit/tome-engine`, `@wabbit/tome-motion`, `@wabbit/tome-rpg`, `@wabbit/tome-webgl` — from `>=18` - `@wabbit/tome-ui` — from `>=18.0.0` The `^18` devDependency pins on the two block-shaped packages were already fiction: the root `pnpm.overrides` pins `@types/react` to `19.2.14`, so both have been building against React 19 types regardless. Correcting them changes the manifest, not the resolved tree. Consumer impact: a React 18 consumer can no longer install these. That install was already impossible with the kernel in the graph.
  • 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.
  • 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.
v0.1.2patch

26dfa07: `vitest run` exited 1 with "No test files found" in these two packages — neither has any test files yet. Added `--passWithNoTests` to the `test` script so CI doesn't fail on an empty suite. Script-only change; no runtime behavior changed. Both packages still need real test coverage added (tracked separately, not fixed here).

  • 26dfa07: `vitest run` exited 1 with "No test files found" in these two packages — neither has any test files yet. Added `--passWithNoTests` to the `test` script so CI doesn't fail on an empty suite. Script-only change; no runtime behavior changed. Both packages still need real test coverage added (tracked separately, not fixed here).
v0.1.1patch

6bb2f5a: Rename `@wabbit/tome-games` → `@wabbit/tome-engine`. The package is a domain-neutral real-time runtime substrate (loop / input / math / state / trace) — the "games" name implied games-only and hid its reuse for rich web-dev. No bound consumers at rename time; the `tome-games` name is freed for the fork-and-own game starter. See the Games Layer Design spec Amendment A1 (2026-05-31).

  • 6bb2f5a: Rename `@wabbit/tome-games` → `@wabbit/tome-engine`. The package is a domain-neutral real-time runtime substrate (loop / input / math / state / trace) — the "games" name implied games-only and hid its reuse for rich web-dev. No bound consumers at rename time; the `tome-games` name is freed for the fork-and-own game starter. See the Games Layer Design spec Amendment A1 (2026-05-31).

Theme Groundwork

v0.2.3
v0.2.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.2.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.2.1patch

5a84171: Groundwork's band padding now matches the mockups between the clamp ends: the default band is 115px at 1440 (it was 102px). The conversion from the mockups' 16px root to the 18px root scaled the viewport (`vw`) terms of the band clamps too, though a `vw` term does not depend on the root. They are the mockups' own values again; the rem ends, which were converted correctly, are unchanged: `--tome-section-py` `clamp(3.556rem, 8vw, 6.667rem)` (was 7.1vw), `--tome-section-py-sm` and `--groundwork-hero-py` `clamp(1.778rem, 5vw, 4rem)` (were 4.4vw), and `--groundwork-band-py` `clamp(2.667rem, 6vw, 4.444rem)` (was 5.3vw). The type clamps already kept the mockups' `vw` terms.

  • 5a84171: Groundwork's band padding now matches the mockups between the clamp ends: the default band is 115px at 1440 (it was 102px). The conversion from the mockups' 16px root to the 18px root scaled the viewport (`vw`) terms of the band clamps too, though a `vw` term does not depend on the root. They are the mockups' own values again; the rem ends, which were converted correctly, are unchanged: `--tome-section-py` `clamp(3.556rem, 8vw, 6.667rem)` (was 7.1vw), `--tome-section-py-sm` and `--groundwork-hero-py` `clamp(1.778rem, 5vw, 4rem)` (were 4.4vw), and `--groundwork-band-py` `clamp(2.667rem, 6vw, 4.444rem)` (was 5.3vw). The type clamps already kept the mockups' `vw` terms.
  • e8213a7: The next-step card's "Get my estimate" pill is 52px tall, as in the mockup. Now that the link carries its `data-panel-cta` hook, the theme's pill reaches it, and the block's own vertical padding made it 54px; the theme sets that padding to 0.
v0.2.0minor

b877681: Groundwork now uses the v1.1 platform hooks in place of its workarounds. - **Scroll reveal.** The theme turns on tome-ui's opt-in platform scroll reveal (`--tome-reveal: on`) and maps `--tome-reveal-rise`, `-duration` and `-stagger` to its `--groundwork-reveal-*` values, and `--tome-reveal-ease` to `--tome-motion-ease-out`. It runs only where the site mounts `<TomeReveal />` from `@wabbit/tome-ui/reveal`. Before this, the theme declared reveal tokens that nothing read. - **Footer 7.** The `--tome-color-muted*` rebinding is gone, now that tome-chrome maps `backgroundColor: 'muted'` to the declared `surface-muted` / `on-surface-muted` pair. The brand-and-links row (`[data-footer-top]`, which replaces the `:has(> [data-footer-nav])` selector) and the link lists take `--tome-color-foreground`, as in the mockup. The bottom bar keeps chrome's muted ink. Sites on a tome-chrome without the footer fix get a transparent footer. - **Header.** The phone-width Call button (`[data-header-call]`) is a compact primary-ink pill with an accent-underline hover, set through `--tome-header-call-*`. An inline CTA (`link.appearance: 'inline'`, for a phone number) stays plain text in tabular figures, with the nav links' accent hover, and is no longer given the pill radius. - **Hero.** The proof line's bold lead-in (`[data-hero-proof-lead]`) reads in the page ink, and the status dot keeps the mockup's gap inside the block's no-wrap `[data-hero-proof-start]` run. - **Estimate.** The eyebrow (`[data-contact-eyebrow]`) is the section label: an accent tick, then the meta voice. With a form note, the card's hairline runs under the head row (`[data-contact-card-head]`), and the note (`[data-contact-form-note]`) is set in the meta voice. - **Process.** Each step's outcome line (`[data-phase-outcome]`) reads as the mockup's step time: upright and semibold through `--tome-dossier-phase-outcome-style` / `-weight`, in the meta voice and the band ink. - **Next-step card.** The card label (`[data-panel-kicker]`) is in the meta voice and the text-safe accent. - **Day time table.** In an `editorialSpread`, an ordered list whose items open with a bold time is set as a ruled time table: a display-type time column with tabular figures, hairlines between rows, and no list numbers. Other ordered lists are untouched.

  • b877681: Groundwork now uses the v1.1 platform hooks in place of its workarounds. - **Scroll reveal.** The theme turns on tome-ui's opt-in platform scroll reveal (`--tome-reveal: on`) and maps `--tome-reveal-rise`, `-duration` and `-stagger` to its `--groundwork-reveal-*` values, and `--tome-reveal-ease` to `--tome-motion-ease-out`. It runs only where the site mounts `<TomeReveal />` from `@wabbit/tome-ui/reveal`. Before this, the theme declared reveal tokens that nothing read. - **Footer 7.** The `--tome-color-muted*` rebinding is gone, now that tome-chrome maps `backgroundColor: 'muted'` to the declared `surface-muted` / `on-surface-muted` pair. The brand-and-links row (`[data-footer-top]`, which replaces the `:has(> [data-footer-nav])` selector) and the link lists take `--tome-color-foreground`, as in the mockup. The bottom bar keeps chrome's muted ink. Sites on a tome-chrome without the footer fix get a transparent footer. - **Header.** The phone-width Call button (`[data-header-call]`) is a compact primary-ink pill with an accent-underline hover, set through `--tome-header-call-*`. An inline CTA (`link.appearance: 'inline'`, for a phone number) stays plain text in tabular figures, with the nav links' accent hover, and is no longer given the pill radius. - **Hero.** The proof line's bold lead-in (`[data-hero-proof-lead]`) reads in the page ink, and the status dot keeps the mockup's gap inside the block's no-wrap `[data-hero-proof-start]` run. - **Estimate.** The eyebrow (`[data-contact-eyebrow]`) is the section label: an accent tick, then the meta voice. With a form note, the card's hairline runs under the head row (`[data-contact-card-head]`), and the note (`[data-contact-form-note]`) is set in the meta voice. - **Process.** Each step's outcome line (`[data-phase-outcome]`) reads as the mockup's step time: upright and semibold through `--tome-dossier-phase-outcome-style` / `-weight`, in the meta voice and the band ink. - **Next-step card.** The card label (`[data-panel-kicker]`) is in the meta voice and the text-safe accent. - **Day time table.** In an `editorialSpread`, an ordered list whose items open with a bold time is set as a ruled time table: a display-type time column with tabular figures, hairlines between rows, and no list numbers. Other ordered lists are untouched.
v0.1.1patch

b659c3d: Footers now run the full site width on the same edges as the header and page grid. The theme sets chrome's shared container contract (`--tome-chrome-content-max: 100%`, `--tome-chrome-content-inline: clamp(1rem, 4vw, 3rem)`) instead of leaving footers in their default 1200px box.

  • b659c3d: Footers now run the full site width on the same edges as the header and page grid. The theme sets chrome's shared container contract (`--tome-chrome-content-max: 100%`, `--tome-chrome-content-inline: clamp(1rem, 4vw, 3rem)`) instead of leaving footers in their default 1200px box.
v0.1.0minor

cbd2d32: New theme: Groundwork, for trades, home services and appointment-based local businesses, activated with `data-tome-theme="groundwork"`. - Four palettes (Timber, the default; Brick; Harbor; Field), each in light and dark, selected with `data-tome-palette`. Every palette sets Layer 1, the `*-text` literals and the fixed-band ink family, contrast-checked (body at least 4.5:1, input outlines and accent marks at least 3:1; Harbor's yellow carries a navy ring on light grounds). - Self-hosted OFL fonts: Zilla Slab (500, 600, 700) and Hanken Grotesk (400 to 700), latin-subset woff2 with their licenses, via `./fonts.css`, the `./fonts` descriptors or `./fonts/*` for your own host. Each `--font-*` slot is paired with its `--tome-type-*` alias. - Treatments through public hooks only: the dark claims band (`proposal-stat-strip`), the Job Log (`dated-ledger`), the `quick-ask` hero, the `estimate` contact band and its form card, the `rail` process band, the margin facts, the `info-panel` next-step card, Navbar 7, footers 7 and 11, and the mobile action bar. The accent is used only as punctuation. - One opt-in effect: a measuring-grid texture on the estimate band (`data-groundwork-effect="measuring-grid"`). - `./register` adds the theme to tome-core's theme registry. Requires `@wabbit/tome-ui` 0.17.1 or later; `@wabbit/tome-core` is an optional peer.

  • cbd2d32: New theme: Groundwork, for trades, home services and appointment-based local businesses, activated with `data-tome-theme="groundwork"`. - Four palettes (Timber, the default; Brick; Harbor; Field), each in light and dark, selected with `data-tome-palette`. Every palette sets Layer 1, the `*-text` literals and the fixed-band ink family, contrast-checked (body at least 4.5:1, input outlines and accent marks at least 3:1; Harbor's yellow carries a navy ring on light grounds). - Self-hosted OFL fonts: Zilla Slab (500, 600, 700) and Hanken Grotesk (400 to 700), latin-subset woff2 with their licenses, via `./fonts.css`, the `./fonts` descriptors or `./fonts/*` for your own host. Each `--font-*` slot is paired with its `--tome-type-*` alias. - Treatments through public hooks only: the dark claims band (`proposal-stat-strip`), the Job Log (`dated-ledger`), the `quick-ask` hero, the `estimate` contact band and its form card, the `rail` process band, the margin facts, the `info-panel` next-step card, Navbar 7, footers 7 and 11, and the mobile action bar. The accent is used only as punctuation. - One opt-in effect: a measuring-grid texture on the estimate band (`data-groundwork-effect="measuring-grid"`). - `./register` adds the theme to tome-core's theme registry. Requires `@wabbit/tome-ui` 0.17.1 or later; `@wabbit/tome-core` is an optional peer.

Theme Counsel

v0.2.0
v0.2.0minor

65020e0: **BREAKING:** the custom property `--counsel-honour-size` is now `--counsel-honor-size`. Update any override that sets the old name.

  • 65020e0: **BREAKING:** the custom property `--counsel-honour-size` is now `--counsel-honor-size`. Update any override that sets the old name.
v0.1.4patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.3patch

ee6cf00: Counsel's practice crumb sets its section sign in the accent text color, through the info panel's new `data-section-label-mark` hook.

  • ee6cf00: Counsel's practice crumb sets its section sign in the accent text color, through the info panel's new `data-section-label-mark` hook.
v0.1.2patch

e8213a7: Counsel's intake, footer and section openers now sit where the mockups put them from 1024px. - **Intake.** The form no longer overlaps the heading and deck. The `estimate` variant places its form card on rows 1 and 2 (and its call, hours and areas on row 2) from 1024px; the theme now resets both to `auto`, so the opener holds the first row and the form follows it in the reading column, with the confidentiality aside beside it in the right margin lane. With a `promise`, the promise leads the reading column and the aside runs beside the promise and the form. - **Confidentiality aside.** The note is the small UI-face size again and its label is ink. The deck rule matched the aside's paragraphs (the aside renders inside the intro) and set them at the deck size in muted ink; it now leaves the aside out. - **Footer 7.** From 1024px the link columns start on the reading column, as the fine print does, with the brand in the left margin lane. The nav no longer shrinks to make room for the brand column's width cap (it started 227px late at 1440). - **FAQ and people openers.** From 1024px the heading starts exactly on the reading column. The blocks' header `gap` became a column gap when the theme made the header a subgrid and moved the heading 2px. - **Intake submit row.** The submit button starts the row at the reading column, with the response promise after it, as in the mockup (it sat right of center, the forms default). The theme sets tome-forms' new `--tome-forms-submit-justify` and, for earlier forms releases, the row's alignment directly.

  • e8213a7: Counsel's intake, footer and section openers now sit where the mockups put them from 1024px. - **Intake.** The form no longer overlaps the heading and deck. The `estimate` variant places its form card on rows 1 and 2 (and its call, hours and areas on row 2) from 1024px; the theme now resets both to `auto`, so the opener holds the first row and the form follows it in the reading column, with the confidentiality aside beside it in the right margin lane. With a `promise`, the promise leads the reading column and the aside runs beside the promise and the form. - **Confidentiality aside.** The note is the small UI-face size again and its label is ink. The deck rule matched the aside's paragraphs (the aside renders inside the intro) and set them at the deck size in muted ink; it now leaves the aside out. - **Footer 7.** From 1024px the link columns start on the reading column, as the fine print does, with the brand in the left margin lane. The nav no longer shrinks to make room for the brand column's width cap (it started 227px late at 1440). - **FAQ and people openers.** From 1024px the heading starts exactly on the reading column. The blocks' header `gap` became a column gap when the theme made the header a subgrid and moved the heading 2px. - **Intake submit row.** The submit button starts the row at the reading column, with the response promise after it, as in the mockup (it sat right of center, the forms default). The theme sets tome-forms' new `--tome-forms-submit-justify` and, for earlier forms releases, the row's alignment directly.
v0.1.1patch

6e0c217: Fixes from the Counsel starter demo, measured against the v2 mockups and written on the hooks and tokens the blocks, chrome and forms now publish. - **Compact header no longer turns pink in Chromium.** Every `color-mix()` toward `transparent` (the header scrim, the photo labels, the text selection) now mixes in oklab. In oklch, the missing hue of `transparent` carried into the result, and Chromium painted the near-neutral ground at hue 0. A test bans `color-mix(in oklch, … transparent)` anywhere in the theme. - **Section openers.** The FAQ, the people grid and the quotes-grid open like every other labeled section through the shared header-group hooks (`data-block-header`, `data-block-eyebrow`, `data-block-heading`, `data-block-subtext`): the label in small caps, the heading at the h2 size, the deck. From 1024px each header group is a subgrid of the page, so the label sits in the left margin lane and the heading runs from the reading column. - **FAQ.** With `calloutStyle: 'line'` (`data-faq-callout-style="line"`), the bridge is the mockup's plain line: a serif sentence and an accent-text link (`data-faq-callout-link`) with an arrow. Set it on a Counsel site; the box style keeps the block's own panel and button. From 1024px the list and the callout (`data-faq-callout`) sit in the reading column. - **Intake.** The confidentiality aside's label (the aside's own `<p>`) is set in serif small caps, and its body in small UI-face type. From 1024px the band draws the page's lanes: the label in the left margin, the heading and form in the reading column, the aside in the right margin lane, sticky. Form rules key on `data-contact-form-slot`. The band is padded once, on the section. The `(optional)` marker is muted and regular through `--tome-forms-optional-color` and `--tome-forms-optional-weight`. - **Matters ledger.** With `chipPlacement: 'dateline'`, the matter type sits under "No. 1" in the dateline's small caps (`[data-ledger-date] [data-ledger-chip]`), with no pill or bullet. - **Client voice.** Four columns from 1024px (`--tome-testimonial-grid-cols: 4`). The `eyebrow` is the small-caps label and the section title the heading. The starred stat unit is 0.45em through `--tome-testimonial-stat-unit-size`. - **People.** Four columns from 1024px (`--tome-roster-people-cols: 4`), on the variant's content lane. - **Footer 7.** A text row's emphasis (`data-footer-text-strong`, the office names) is ink at 500, and grouped rows (`data-footer-row-group`) sit tight (`--tome-footer-row-group-gap: 0`). From 1024px the columns and the fine print start on the reading column (`--tome-footer-fine-print-inset`), the fine print keeping its 600px text measure. - **Practice page head.** The crumb is the serif in small caps through `--tome-info-panel-label-font`, `-case`, `-tracking` and `-caps`, the linked part and the rest (`data-section-label-link`, `data-section-label-rest`) in the same voice. "Book a consultation" (`data-panel-cta`) is an accent-text link with an arrow, not a full-width filled button. - **Other practices.** Cells are reached by `data-zone-cell`. Column tokens are set only inside media queries, since the blocks apply them at every width.

  • 6e0c217: Fixes from the Counsel starter demo, measured against the v2 mockups and written on the hooks and tokens the blocks, chrome and forms now publish. - **Compact header no longer turns pink in Chromium.** Every `color-mix()` toward `transparent` (the header scrim, the photo labels, the text selection) now mixes in oklab. In oklch, the missing hue of `transparent` carried into the result, and Chromium painted the near-neutral ground at hue 0. A test bans `color-mix(in oklch, … transparent)` anywhere in the theme. - **Section openers.** The FAQ, the people grid and the quotes-grid open like every other labeled section through the shared header-group hooks (`data-block-header`, `data-block-eyebrow`, `data-block-heading`, `data-block-subtext`): the label in small caps, the heading at the h2 size, the deck. From 1024px each header group is a subgrid of the page, so the label sits in the left margin lane and the heading runs from the reading column. - **FAQ.** With `calloutStyle: 'line'` (`data-faq-callout-style="line"`), the bridge is the mockup's plain line: a serif sentence and an accent-text link (`data-faq-callout-link`) with an arrow. Set it on a Counsel site; the box style keeps the block's own panel and button. From 1024px the list and the callout (`data-faq-callout`) sit in the reading column. - **Intake.** The confidentiality aside's label (the aside's own `<p>`) is set in serif small caps, and its body in small UI-face type. From 1024px the band draws the page's lanes: the label in the left margin, the heading and form in the reading column, the aside in the right margin lane, sticky. Form rules key on `data-contact-form-slot`. The band is padded once, on the section. The `(optional)` marker is muted and regular through `--tome-forms-optional-color` and `--tome-forms-optional-weight`. - **Matters ledger.** With `chipPlacement: 'dateline'`, the matter type sits under "No. 1" in the dateline's small caps (`[data-ledger-date] [data-ledger-chip]`), with no pill or bullet. - **Client voice.** Four columns from 1024px (`--tome-testimonial-grid-cols: 4`). The `eyebrow` is the small-caps label and the section title the heading. The starred stat unit is 0.45em through `--tome-testimonial-stat-unit-size`. - **People.** Four columns from 1024px (`--tome-roster-people-cols: 4`), on the variant's content lane. - **Footer 7.** A text row's emphasis (`data-footer-text-strong`, the office names) is ink at 500, and grouped rows (`data-footer-row-group`) sit tight (`--tome-footer-row-group-gap: 0`). From 1024px the columns and the fine print start on the reading column (`--tome-footer-fine-print-inset`), the fine print keeping its 600px text measure. - **Practice page head.** The crumb is the serif in small caps through `--tome-info-panel-label-font`, `-case`, `-tracking` and `-caps`, the linked part and the rest (`data-section-label-link`, `data-section-label-rest`) in the same voice. "Book a consultation" (`data-panel-cta`) is an accent-text link with an arrow, not a full-width filled button. - **Other practices.** Cells are reached by `data-zone-cell`. Column tokens are set only inside media queries, since the blocks apply them at every width.
v0.1.0minor

d7d0a32: New theme: Counsel, for law firms and other advisors whose credibility is their product, activated with `data-tome-theme="counsel"`. - Four palettes (Vellum, the default; Chancery; Sage; Slate), each in light and a derived dark, selected with `data-tome-palette`. Every palette sets Layer 1, the `*-text` literals, the tinted surface and the fixed-band ink family, contrast-checked (text at least 4.5:1; input outlines, accent marks and the focus ring at least 3:1). - Self-hosted OFL fonts: Source Serif 4 (one variable face per style, roman and italic, with the optical-size axis) for headings, prose and the small-caps meta voice, and Public Sans (400, 500) for the UI only, latin-subset woff2 with their licenses, via `./fonts.css`, the `./fonts` descriptors or `./fonts/*` for your own host. Each `--font-*` slot is paired with its `--tome-type-*` alias. - Treatments through public hooks only: the Navbar 7 lockup and its compact state on scroll, the `split` hero with its portrait plate, the credibility numerals, the `index` practice ledger with section marks, the matters and compact recognition ledgers, the `panel` process step bar, the `people` grid, the `quotes-grid` client voice, the numbered FAQ, the dark intake band with a stacked form, the practice page head, the Annotated Page, the other-practices directory, footer 7 with its fine print, and one look for every compliance label. The accent is used only as punctuation, and nothing casts a shadow. - The platform scroll reveal is on, with Counsel's quieter timing (8px rise, 560ms, 70ms stagger); it runs only where the site mounts `TomeReveal`. - One opt-in effect: a laid-paper texture on the Vellum page ground (`data-counsel-effect="laid-paper"`). - `./register` adds the theme to tome-core's theme registry. Requires `@wabbit/tome-ui` 0.18.0 or later; `@wabbit/tome-core` is an optional peer.

  • d7d0a32: New theme: Counsel, for law firms and other advisors whose credibility is their product, activated with `data-tome-theme="counsel"`. - Four palettes (Vellum, the default; Chancery; Sage; Slate), each in light and a derived dark, selected with `data-tome-palette`. Every palette sets Layer 1, the `*-text` literals, the tinted surface and the fixed-band ink family, contrast-checked (text at least 4.5:1; input outlines, accent marks and the focus ring at least 3:1). - Self-hosted OFL fonts: Source Serif 4 (one variable face per style, roman and italic, with the optical-size axis) for headings, prose and the small-caps meta voice, and Public Sans (400, 500) for the UI only, latin-subset woff2 with their licenses, via `./fonts.css`, the `./fonts` descriptors or `./fonts/*` for your own host. Each `--font-*` slot is paired with its `--tome-type-*` alias. - Treatments through public hooks only: the Navbar 7 lockup and its compact state on scroll, the `split` hero with its portrait plate, the credibility numerals, the `index` practice ledger with section marks, the matters and compact recognition ledgers, the `panel` process step bar, the `people` grid, the `quotes-grid` client voice, the numbered FAQ, the dark intake band with a stacked form, the practice page head, the Annotated Page, the other-practices directory, footer 7 with its fine print, and one look for every compliance label. The accent is used only as punctuation, and nothing casts a shadow. - The platform scroll reveal is on, with Counsel's quieter timing (8px rise, 560ms, 70ms stagger); it runs only where the site mounts `TomeReveal`. - One opt-in effect: a laid-paper texture on the Vellum page ground (`data-counsel-effect="laid-paper"`). - `./register` adds the theme to tome-core's theme registry. Requires `@wabbit/tome-ui` 0.18.0 or later; `@wabbit/tome-core` is an optional peer.

Theme Table

v0.1.4
v0.1.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.2patch

a97f0e1: README: hours, phone and address come from the business profile (`@wabbit/tome-local`), and the Now Bar reads it through `LocalLiveStatusAnnouncementFromProfile`.

  • a97f0e1: README: hours, phone and address come from the business profile (`@wabbit/tome-local`), and the Now Bar reads it through `LocalLiveStatusAnnouncementFromProfile`.
v0.1.1patch

323534d: Table now styles the ordering surfaces, the venue hero's action and Footer 7 completely, so they match the mockups at 1440 and 375. - **Ordering.** Checkout is the ink pill (primary fill, on-primary label at rest and on hover, 54px, no underline). Before this, tome-ui's link color made the label ink on ink. The floating pill is the 54px ink pill with the pill shadow, and its label and total spread across the phone width. The pickup select gets a 1.5px `--input` edge, the md radius, the surface fill and a caret. The close button and line steppers keep their hairline ring. These rules sit at (0,2,1) or higher (`[data-order-drawer] a[data-order-checkout]`, `button[data-order-pill]`), so they still win once the island's hook selectors leave `:where()`, as long as the island stays at (0,2,0) or below. - **Venue hero.** The action (`[data-hero-actions] a`) is the 50px ink pill in Work Sans 600. The hero's links carry no `data-appearance`, so they had kept the block's small button. The hero line keeps the mockup's 34ch measure (`--table-hero-line-measure`). - **Footer 7.** From 1024px the footer sits on the page grid's lanes. The wordmark is in the left marginalia lane, the links run in one row from reading-start, and the copyright is right-aligned in the right lane. The lane width is `--table-footer-lane`, computed from the 16-column grid and `--tome-grid-gap`. Links and plain rows share the muted ink and the body weight. The copyright bar takes the footer size. The wordmark no longer takes Footer 7's 2.75rem logo height, and phones keep the brand and copyright left-aligned.

  • 323534d: Table now styles the ordering surfaces, the venue hero's action and Footer 7 completely, so they match the mockups at 1440 and 375. - **Ordering.** Checkout is the ink pill (primary fill, on-primary label at rest and on hover, 54px, no underline). Before this, tome-ui's link color made the label ink on ink. The floating pill is the 54px ink pill with the pill shadow, and its label and total spread across the phone width. The pickup select gets a 1.5px `--input` edge, the md radius, the surface fill and a caret. The close button and line steppers keep their hairline ring. These rules sit at (0,2,1) or higher (`[data-order-drawer] a[data-order-checkout]`, `button[data-order-pill]`), so they still win once the island's hook selectors leave `:where()`, as long as the island stays at (0,2,0) or below. - **Venue hero.** The action (`[data-hero-actions] a`) is the 50px ink pill in Work Sans 600. The hero's links carry no `data-appearance`, so they had kept the block's small button. The hero line keeps the mockup's 34ch measure (`--table-hero-line-measure`). - **Footer 7.** From 1024px the footer sits on the page grid's lanes. The wordmark is in the left marginalia lane, the links run in one row from reading-start, and the copyright is right-aligned in the right lane. The lane width is `--table-footer-lane`, computed from the 16-column grid and `--tome-grid-gap`. Links and plain rows share the muted ink and the body weight. The copyright bar takes the footer size. The wordmark no longer takes Footer 7's 2.75rem logo height, and phones keep the brand and copyright left-aligned.
v0.1.0minor

e957219: New theme: Table, for places people visit (restaurants and cafés first, then bars, bakeries, breweries, venues, salons and boutique retail), activated with `data-tome-theme="table"`. - Two palettes (Linen, the default; Garden), each in light and a derived dark, selected with `data-tome-palette`. Every palette sets Layer 1, the `*-text` literals, the tinted surface, the fixed-band ink family and Table's private colors (the band action, the stamp, the illustrative map, the scrim), contrast-checked (text at least 4.5:1; field outlines, accent marks and the focus ring at least 3:1). Each palette also sets the display face's axes (Linen: opsz 144, SOFT 0, WONK 0 at 340; Garden: opsz 40, SOFT 100, WONK 1 at 580), the body weight and the radius scale. - Self-hosted OFL fonts: Fraunces (the fontsource full cut, one variable face carrying opsz, wght, SOFT and WONK) for display, and Work Sans (variable weight) for the body, the UI, the meta voice and every tabular figure, latin-subset woff2 with their licenses, via `./fonts.css`, the `./fonts` descriptors or `./fonts/*` for your own host. Each `--font-*` slot is paired with its `--tome-type-*` alias, and every Fraunces surface states its axes. - Treatments through public hooks only (the Table v1 hook contract): the Now Bar in chrome's announcement slot, Navbar 5 with the static header and the order button, the `venue` hero (photo behind with a 62% scrim floor, or above with the name on the cloth), the menu teaser, the `card-grid` menu (featured dish with the stamp, the numbered rail, mastheads, photo cards with in-card details, one grid-breaker) and the `ledger`, the ordering island's stepper, drawer and pill, the `reserve` band with sample slot chips and the pickup band, Find Us, and footer 7 in one row. The accent is punctuation; only four things cast a shadow. - The platform scroll reveal is on, with Table's timing (12px rise, 420ms, 60ms stagger); it runs only where the site mounts `TomeReveal`. - One opt-in effect: a faint static grain (`data-table-effect="grain"`). - `./register` adds the theme to tome-core's theme registry. Requires `@wabbit/tome-ui` 0.18.0 or later; `@wabbit/tome-core` is an optional peer.

  • e957219: New theme: Table, for places people visit (restaurants and cafés first, then bars, bakeries, breweries, venues, salons and boutique retail), activated with `data-tome-theme="table"`. - Two palettes (Linen, the default; Garden), each in light and a derived dark, selected with `data-tome-palette`. Every palette sets Layer 1, the `*-text` literals, the tinted surface, the fixed-band ink family and Table's private colors (the band action, the stamp, the illustrative map, the scrim), contrast-checked (text at least 4.5:1; field outlines, accent marks and the focus ring at least 3:1). Each palette also sets the display face's axes (Linen: opsz 144, SOFT 0, WONK 0 at 340; Garden: opsz 40, SOFT 100, WONK 1 at 580), the body weight and the radius scale. - Self-hosted OFL fonts: Fraunces (the fontsource full cut, one variable face carrying opsz, wght, SOFT and WONK) for display, and Work Sans (variable weight) for the body, the UI, the meta voice and every tabular figure, latin-subset woff2 with their licenses, via `./fonts.css`, the `./fonts` descriptors or `./fonts/*` for your own host. Each `--font-*` slot is paired with its `--tome-type-*` alias, and every Fraunces surface states its axes. - Treatments through public hooks only (the Table v1 hook contract): the Now Bar in chrome's announcement slot, Navbar 5 with the static header and the order button, the `venue` hero (photo behind with a 62% scrim floor, or above with the name on the cloth), the menu teaser, the `card-grid` menu (featured dish with the stamp, the numbered rail, mastheads, photo cards with in-card details, one grid-breaker) and the `ledger`, the ordering island's stepper, drawer and pill, the `reserve` band with sample slot chips and the pickup band, Find Us, and footer 7 in one row. The accent is punctuation; only four things cast a shadow. - The platform scroll reveal is on, with Table's timing (12px rise, 420ms, 60ms stagger); it runs only where the site mounts `TomeReveal`. - One opt-in effect: a faint static grain (`data-table-effect="grain"`). - `./register` adds the theme to tome-core's theme registry. Requires `@wabbit/tome-ui` 0.18.0 or later; `@wabbit/tome-core` is an optional peer.

Theme Folio

v0.2.0
v0.2.0minor

65020e0: **BREAKING:** the paper texture effect value is now `data-folio-effect="paper-fiber"` (was `paper-fibre`), and its stylesheet is `effects/paper-fiber.css`. Update any page that sets the old value.

  • 65020e0: **BREAKING:** the paper texture effect value is now `data-folio-effect="paper-fiber"` (was `paper-fibre`), and its stylesheet is `effects/paper-fiber.css`. Update any page that sets the old value.
  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.1.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.0minor

e79dbc3: New package: `@wabbit/tome-theme-folio`, a Tome theme for coaches, counselors, consultants and authors whose business is themselves. It reads like a good book by its author: Piazzolla with Alegreya Sans, small old-style folios, soft paper radii, colored inks, and an accent that only ever punctuates. - **Four palettes, each in light and a derived opposite mode.** Deckle (default), Clay, Heath and Lamplight, activated by `data-tome-theme="folio"` and `data-tome-palette`. The three light palettes paint no dark band: sections shift tone and the footer is its own zone. Lamplight is a designed dark page with a derived light page, and opens dark through the manifest's `defaultMode` (the `FOLIO_PALETTE_DEFAULT_MODES` map says the same for a site's own switcher). Every text pair clears 4.5:1 and every mark, field outline and focus ring 3:1 in all eight modes, including a field outline on the muted band, and the tests re-measure them. - **Self-hosted OFL fonts.** Piazzolla (the upstream release, with old-style figures and the new-tab arrow in its latin subset) and Alegreya Sans 400, 500 and 400 italic, through `./fonts.css`, `getFolioFontStyles()` or `next/font/local`. - **Static header, with the call to action kept in the bar at tablet widths.** Navbar 7 is set static on every Folio page, with the ink-pill call to action and a bordered "Menu" button; with the Header global's `ctaVisibleBelowDesktop` the pill stays beside "Menu" from 768px. - **The hero.** The `split` hero is a portrait on the first five columns with the copy from the seventh, a spoken line in italic, two pills, one proof line and a still-life motif. - **The Two Ways ledger.** `practice-modes` `ledger` as two rounded paths compared row by row, the recommended one under an accent rule, the "Good fit if" chips on a fill that stays visible on the surface card (`--folio-chip-fill-card`, the surface mixed half toward the chip fill), and a tab list on a phone. - **The Margin Voice and the essay page.** Notes in the margin from 1280px and inline below it, the author's aside in italic, symbol footnote marks with an end list, a folio for each part (the opening text is part 1, so the first part prints 2), a drop cap on the paragraph the renderer marks (`data-drop-cap-target`), a margin lane that stands further from the text than the bare page grid does (the `--folio-note-offset` token, with `--folio-note-bleed` giving back the note width it takes, both computed from the lane columns), the margin header and the running head with its reading progress, and the epigraph. - **The closing and the Reading Room.** The `contact` closing with the portrait, motif, link-out pill and sign-off beside the form; the `archive` `filed` lead, ruled rows, footer row and the single "next" teaser; surface-filled form fields and path options. - **The chapters and the fit pair.** `dated-ledger` rows with the year in the left lane and the photo faded from the left, and `fit-prose` as two third-level labels over body text. - **The footer on a phone.** Below 768px footer 7 is one left-aligned column, the brand first and on the left, each group under it. - **Band rhythm and tone.** The endorsements, the Reading Room index and the questions take the section padding the other bands use, and the first two sit on the surface tone (the questions stay on the ground); the paper fiber reaches those two surface bands. - **The closing form.** The path choices are a wrapping row of pills (the tome-forms radios and the Form Builder radios alike), name and email share a row from tablet up, a one-step form's repeated step label is hidden accessibly (keyed on `data-form-step-count="1"`), and the monthly-letter sign-up is one inline row of email field and button with its line at the annotation size. The send button sits at the start of its line with no hairline above it, the required marker is a small muted asterisk (the control keeps its required state), and the still-life motif sits directly under the label in the left lane. - **Openers.** The headings of the ledger, the chapters and the fit pair sit on the prose line from 1024px, and the fit columns start there too; one shared rule. The Reading Room opens with a folio, a label, a heading and a deck, and keeps the page gutter on a phone. A deck (`data-block-deck`) under a heading is styled the same everywhere: the surface ink, 16px under the heading. - **The figures, the endorsements and the questions.** `proposal-stat-strip` (oversized-light old-style numerals with small captions between two hairlines, three equal cells 32px apart, captions on one line), `testimonial` in the `quotes-grid` layout (three italic serif quotes with accent-text marks, a name and affiliation under each on one plain caption line in the meta voice, a compliance label as a small meta line, no portraits or stat tile; the section title may be an `h2` or an `h3`) and `faq` (hairline questions, a Piazzolla plus that becomes a minus, the bridge as a plain line) are styled and listed in `FOLIO_STYLED_BLOCKS`. A band that rendered nothing is not counted. - **Folios.** Section numbers are a CSS counter in page order, printed in the left margin lane on the labels of the blocks that open a section. - **One opt-in effect**, `data-folio-effect="paper-fibre"`, a faint grain on the ground and surface bands in Deckle and Clay light. Peers: `@wabbit/tome-ui` `>=0.20.0` (the font-waiting reveal) and optional `@wabbit/tome-core` `>=1.23.0` (a palette's `defaultMode`). `FOLIO_STYLED_BLOCKS` lists the blocks that are styled.

  • e79dbc3: New package: `@wabbit/tome-theme-folio`, a Tome theme for coaches, counselors, consultants and authors whose business is themselves. It reads like a good book by its author: Piazzolla with Alegreya Sans, small old-style folios, soft paper radii, colored inks, and an accent that only ever punctuates. - **Four palettes, each in light and a derived opposite mode.** Deckle (default), Clay, Heath and Lamplight, activated by `data-tome-theme="folio"` and `data-tome-palette`. The three light palettes paint no dark band: sections shift tone and the footer is its own zone. Lamplight is a designed dark page with a derived light page, and opens dark through the manifest's `defaultMode` (the `FOLIO_PALETTE_DEFAULT_MODES` map says the same for a site's own switcher). Every text pair clears 4.5:1 and every mark, field outline and focus ring 3:1 in all eight modes, including a field outline on the muted band, and the tests re-measure them. - **Self-hosted OFL fonts.** Piazzolla (the upstream release, with old-style figures and the new-tab arrow in its latin subset) and Alegreya Sans 400, 500 and 400 italic, through `./fonts.css`, `getFolioFontStyles()` or `next/font/local`. - **Static header, with the call to action kept in the bar at tablet widths.** Navbar 7 is set static on every Folio page, with the ink-pill call to action and a bordered "Menu" button; with the Header global's `ctaVisibleBelowDesktop` the pill stays beside "Menu" from 768px. - **The hero.** The `split` hero is a portrait on the first five columns with the copy from the seventh, a spoken line in italic, two pills, one proof line and a still-life motif. - **The Two Ways ledger.** `practice-modes` `ledger` as two rounded paths compared row by row, the recommended one under an accent rule, the "Good fit if" chips on a fill that stays visible on the surface card (`--folio-chip-fill-card`, the surface mixed half toward the chip fill), and a tab list on a phone. - **The Margin Voice and the essay page.** Notes in the margin from 1280px and inline below it, the author's aside in italic, symbol footnote marks with an end list, a folio for each part (the opening text is part 1, so the first part prints 2), a drop cap on the paragraph the renderer marks (`data-drop-cap-target`), a margin lane that stands further from the text than the bare page grid does (the `--folio-note-offset` token, with `--folio-note-bleed` giving back the note width it takes, both computed from the lane columns), the margin header and the running head with its reading progress, and the epigraph. - **The closing and the Reading Room.** The `contact` closing with the portrait, motif, link-out pill and sign-off beside the form; the `archive` `filed` lead, ruled rows, footer row and the single "next" teaser; surface-filled form fields and path options. - **The chapters and the fit pair.** `dated-ledger` rows with the year in the left lane and the photo faded from the left, and `fit-prose` as two third-level labels over body text. - **The footer on a phone.** Below 768px footer 7 is one left-aligned column, the brand first and on the left, each group under it. - **Band rhythm and tone.** The endorsements, the Reading Room index and the questions take the section padding the other bands use, and the first two sit on the surface tone (the questions stay on the ground); the paper fiber reaches those two surface bands. - **The closing form.** The path choices are a wrapping row of pills (the tome-forms radios and the Form Builder radios alike), name and email share a row from tablet up, a one-step form's repeated step label is hidden accessibly (keyed on `data-form-step-count="1"`), and the monthly-letter sign-up is one inline row of email field and button with its line at the annotation size. The send button sits at the start of its line with no hairline above it, the required marker is a small muted asterisk (the control keeps its required state), and the still-life motif sits directly under the label in the left lane. - **Openers.** The headings of the ledger, the chapters and the fit pair sit on the prose line from 1024px, and the fit columns start there too; one shared rule. The Reading Room opens with a folio, a label, a heading and a deck, and keeps the page gutter on a phone. A deck (`data-block-deck`) under a heading is styled the same everywhere: the surface ink, 16px under the heading. - **The figures, the endorsements and the questions.** `proposal-stat-strip` (oversized-light old-style numerals with small captions between two hairlines, three equal cells 32px apart, captions on one line), `testimonial` in the `quotes-grid` layout (three italic serif quotes with accent-text marks, a name and affiliation under each on one plain caption line in the meta voice, a compliance label as a small meta line, no portraits or stat tile; the section title may be an `h2` or an `h3`) and `faq` (hairline questions, a Piazzolla plus that becomes a minus, the bridge as a plain line) are styled and listed in `FOLIO_STYLED_BLOCKS`. A band that rendered nothing is not counted. - **Folios.** Section numbers are a CSS counter in page order, printed in the left margin lane on the labels of the blocks that open a section. - **One opt-in effect**, `data-folio-effect="paper-fibre"`, a faint grain on the ground and surface bands in Deckle and Clay light. Peers: `@wabbit/tome-ui` `>=0.20.0` (the font-waiting reveal) and optional `@wabbit/tome-core` `>=1.23.0` (a palette's `defaultMode`). `FOLIO_STYLED_BLOCKS` lists the blocks that are styled.

Theme Shelf

v0.1.3
v0.1.3patch

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

  • dd19c82: Admin labels, field descriptions, demo content and messages now use American spellings.
v0.1.2patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.1patch

e1010dc: On a phone the Shelf footer now stacks into one left-aligned column, with each store fact on its own line under a hairline instead of squeezed into a narrow third.

  • e1010dc: On a phone the Shelf footer now stacks into one left-aligned column, with each store fact on its own line under a hairline instead of squeezed into a narrow third.
v0.1.0minor

632d11a: New package: `@wabbit/tome-theme-shelf`, a Tome theme for small-batch shops: makers, studios and independent sellers whose product and whose story are one page. It reads like a well-made printed catalog that happens to take your order: the shop speaks in Anybody, an expanded grotesk with tabular figures, and the maker speaks in Spectral. A 2px shelf line under every row, a chamfered edition tag, numbered section openers, and color by commerce role: buy is ink, and the one accent is only ever a notice. - **Four palettes, each in light and a derived opposite mode.** Bisque (default), Stone, Pantry and Smoke, activated by `data-tome-theme="shelf"` and `data-tome-palette`. Smoke is a designed dark page with a derived light page, and opens dark through the manifest's `defaultMode` (the `SHELF_PALETTE_DEFAULT_MODES` map says the same for a site's own switcher). Pantry's bright marmalade is a mark only and never sits behind text. Every text pair clears 4.5:1 and every mark, field outline and focus ring 3:1 in all eight modes, including on the well, and the tests re-measure them. - **Self-hosted OFL fonts.** Anybody (one variable face, `wdth` 100 to 150 and `wght` 300 to 700, with working `tnum` and `case`) and Spectral 400, 400 italic and 600 (with `onum`), subset from the upstream TTFs, through `./fonts.css`, `getShelfFontStyles()` or `next/font/local`. The tests read the shipped woff2 files for their features and axes. - **Navbar 5 that earns its chrome on scroll.** The theme styles the header on chrome's opt-in `data-nav-align="center"` and `data-nav-scrolled` hooks: bare at the top, a surface tone and a hairline once scrolled. - **The hero and the opener.** The `split` hero is the copy on the first eight columns and one photo from column nine, a solid caption chip with the price in tabular figures and the add, and a proof line of plain facts; the promise is Anybody at `wdth` 118. The section opener is the masthead: label in the left lane, title from the prose line, link in the right lane, a hairline under all three. - **The store-facts strip, the shelf line and the edition tag**, as CSS on the `data-store-facts`, `data-shelf-line` and `data-edition-tag` hooks. - **Motion tokens.** `--shelf-spring`, `--shelf-glide` and `--shelf-roll`, each collapsing to an instant swap under `prefers-reduced-motion`, with the platform scroll reveal turned on as a short glide. - **Two opt-in effects**, `data-shelf-effect="keyed-plates"` (keyed product plates multiply into the well in light modes; lit prints in dark and by default) and `data-shelf-effect="grain"` (Pantry light only). Not in this release: the product tile, shelf rows, contents rows, filters, buy box, bag drawer, checkout surfaces, the home bands below the hero, and Footer 7. `SHELF_STYLED_BLOCKS` lists only the blocks that are styled. Checkpoint B round 2: the shop's results and toolbar sit on the main column from reading-start at 1024px and up; the product page's gallery (8 columns) and buy box (7) fill their columns; the making pairs win over the pack's 7fr 5fr split; the compact restock is a chip on the occasion row's plate; a count-only opener is a caption; the lookup facts panel starts level with the task; "What we don't do" loses the ledger's second 2px rule; Footer 7 is tight. Parity pass (2026-10-19): ruled-split opener parts take the content lane below 1024 (the home no longer overflows at 320); the size segment group wraps and fits its lane; label and low-stock chips are fit-content; journal row titles are the 16px UI step; the footer shelf links, breadcrumb and signup band link carry a 44px hit area; a `[data-guide-tile]` treatment stands the guide tile on the shelf line at plate height on the surface.

  • 632d11a: New package: `@wabbit/tome-theme-shelf`, a Tome theme for small-batch shops: makers, studios and independent sellers whose product and whose story are one page. It reads like a well-made printed catalog that happens to take your order: the shop speaks in Anybody, an expanded grotesk with tabular figures, and the maker speaks in Spectral. A 2px shelf line under every row, a chamfered edition tag, numbered section openers, and color by commerce role: buy is ink, and the one accent is only ever a notice. - **Four palettes, each in light and a derived opposite mode.** Bisque (default), Stone, Pantry and Smoke, activated by `data-tome-theme="shelf"` and `data-tome-palette`. Smoke is a designed dark page with a derived light page, and opens dark through the manifest's `defaultMode` (the `SHELF_PALETTE_DEFAULT_MODES` map says the same for a site's own switcher). Pantry's bright marmalade is a mark only and never sits behind text. Every text pair clears 4.5:1 and every mark, field outline and focus ring 3:1 in all eight modes, including on the well, and the tests re-measure them. - **Self-hosted OFL fonts.** Anybody (one variable face, `wdth` 100 to 150 and `wght` 300 to 700, with working `tnum` and `case`) and Spectral 400, 400 italic and 600 (with `onum`), subset from the upstream TTFs, through `./fonts.css`, `getShelfFontStyles()` or `next/font/local`. The tests read the shipped woff2 files for their features and axes. - **Navbar 5 that earns its chrome on scroll.** The theme styles the header on chrome's opt-in `data-nav-align="center"` and `data-nav-scrolled` hooks: bare at the top, a surface tone and a hairline once scrolled. - **The hero and the opener.** The `split` hero is the copy on the first eight columns and one photo from column nine, a solid caption chip with the price in tabular figures and the add, and a proof line of plain facts; the promise is Anybody at `wdth` 118. The section opener is the masthead: label in the left lane, title from the prose line, link in the right lane, a hairline under all three. - **The store-facts strip, the shelf line and the edition tag**, as CSS on the `data-store-facts`, `data-shelf-line` and `data-edition-tag` hooks. - **Motion tokens.** `--shelf-spring`, `--shelf-glide` and `--shelf-roll`, each collapsing to an instant swap under `prefers-reduced-motion`, with the platform scroll reveal turned on as a short glide. - **Two opt-in effects**, `data-shelf-effect="keyed-plates"` (keyed product plates multiply into the well in light modes; lit prints in dark and by default) and `data-shelf-effect="grain"` (Pantry light only). Not in this release: the product tile, shelf rows, contents rows, filters, buy box, bag drawer, checkout surfaces, the home bands below the hero, and Footer 7. `SHELF_STYLED_BLOCKS` lists only the blocks that are styled. Checkpoint B round 2: the shop's results and toolbar sit on the main column from reading-start at 1024px and up; the product page's gallery (8 columns) and buy box (7) fill their columns; the making pairs win over the pack's 7fr 5fr split; the compact restock is a chip on the occasion row's plate; a count-only opener is a caption; the lookup facts panel starts level with the task; "What we don't do" loses the ledger's second 2px rule; Footer 7 is tight. Parity pass (2026-10-19): ruled-split opener parts take the content lane below 1024 (the home no longer overflows at 320); the size segment group wraps and fits its lane; label and low-stock chips are fit-content; journal row titles are the 16px UI step; the footer shelf links, breadcrumb and signup band link carry a 44px hit area; a `[data-guide-tile]` treatment stands the guide tile on the shelf line at plate height on the surface.

Theme Roster

v0.2.0
v0.2.0minor

65020e0: **BREAKING:** the class-schedule enroll button is styled through `[data-schedule-enroll]` (was `[data-schedule-enrol]`). This follows `@wabbit/tome-blocks-local-pack`. Update any override that selects the old attribute. The new selector needs `@wabbit/tome-blocks-local-pack` 0.11.0 or later, so update both together.

  • 65020e0: **BREAKING:** the class-schedule enroll button is styled through `[data-schedule-enroll]` (was `[data-schedule-enrol]`). This follows `@wabbit/tome-blocks-local-pack`. Update any override that selects the old attribute. The new selector needs `@wabbit/tome-blocks-local-pack` 0.11.0 or later, so update both together.
v0.1.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.0minor

daafc5f: New package: `@wabbit/tome-theme-roster`, a Tome theme for businesses that run classes on a timetable and sell a term of them: gymnastics, dance, swim, martial arts, camps and preschools. It reads like a printed gym timetable that happens to take a booking: Bricolage Grotesque for headings and single big numbers, Atkinson Hyperlegible Next for everything a parent reads or taps, a level stripe as the one recurring mark, and a single filled action per page. No accent bars anywhere. - **Four palettes, each in light and a derived dark mode.** Chalk (default, a true red), Lane (pool blue), Camp (pine) and Sprout (cornflower), activated by `data-tome-theme="roster"` and `data-tome-palette`. All four are light by design. Every text pair clears 4.5:1 and every mark, field outline, focus ring and frame edge 3:1 in all eight modes, the timetable frame's roles included, and the tests re-measure them. - **Self-hosted OFL fonts.** Bricolage Grotesque (one variable face, `opsz` 12 to 96 and `wght` 500 to 800, width axis not shipped) and Atkinson Hyperlegible Next (upright and italic, `wght` 400 to 700), both with working `tnum`, subset from the upstream TTFs, through `./fonts.css`, `getRosterFontStyles()` or `next/font/local`. The tests read the shipped woff2 files for their features and axes. - **The section opener and the marks.** A sentence-case label with a 3px accent dash, a short heading and one sentence; the level stripe (14x7 segments, always beside its text, filling once to the real level), age and time tags, and ink spots dots. - **Navbar 1 and Footer 7.** The header on the page ground with a hairline once scrolled, the action-fill call to action and an outline call pill; the footer's text rows and credits line, with no call-to-action band. - **The hero.** The treatment for the class finder (`local-class-finder`, `ask`): the promise, the live line, the pill questions, the result card with the one filled trial button, and a single tall photo lane from grid line 12 to the content edge. - **The timetable frame's tokens and the motion tokens.** `--roster-frame`, `-frame-today`, `-frame-edge` and `-frame-rule`; `--roster-rise`, `--roster-fill` and `--roster-spring`, with the platform scroll reveal turned on as a small rise. Every moving thing has a reduced-motion path. - **Checkpoint A geometry.** The header is the mockup's 68px bar (through Navbar 1's `--tome-navbar1-pad-block`), the menu links stand a `--roster-space-5` apart, and at 320px the wordmark, call pill and stripe dots tighten. The result card's spots line sits in the right column beside the button and the link from 1200px, and the hero pads once (`--tome-finder-pad-y: 0`; the wrapper pads). The block marks read the theme's px stripe tokens (`--tome-level-stripe-*`), its 15px small size (`--tome-text-sm`) and its 56px button (`--tome-finder-cta-min-h`), so they win over the blocks' rem fallbacks. - **The stripe fill** animates only the move into `in`; `armed` is instant, so it no longer collapses and overshoots on mount. - **One opt-in effect**, `data-roster-effect="grain"` (Sprout light only). Not in this release: the timetable, pathway, credentials, fee card, team, FAQ, close and program-page treatments, and the hours-aware header. They arrive with the blocks that emit their hooks; `ROSTER_STYLED_BLOCKS` lists only the blocks that are styled.

  • daafc5f: New package: `@wabbit/tome-theme-roster`, a Tome theme for businesses that run classes on a timetable and sell a term of them: gymnastics, dance, swim, martial arts, camps and preschools. It reads like a printed gym timetable that happens to take a booking: Bricolage Grotesque for headings and single big numbers, Atkinson Hyperlegible Next for everything a parent reads or taps, a level stripe as the one recurring mark, and a single filled action per page. No accent bars anywhere. - **Four palettes, each in light and a derived dark mode.** Chalk (default, a true red), Lane (pool blue), Camp (pine) and Sprout (cornflower), activated by `data-tome-theme="roster"` and `data-tome-palette`. All four are light by design. Every text pair clears 4.5:1 and every mark, field outline, focus ring and frame edge 3:1 in all eight modes, the timetable frame's roles included, and the tests re-measure them. - **Self-hosted OFL fonts.** Bricolage Grotesque (one variable face, `opsz` 12 to 96 and `wght` 500 to 800, width axis not shipped) and Atkinson Hyperlegible Next (upright and italic, `wght` 400 to 700), both with working `tnum`, subset from the upstream TTFs, through `./fonts.css`, `getRosterFontStyles()` or `next/font/local`. The tests read the shipped woff2 files for their features and axes. - **The section opener and the marks.** A sentence-case label with a 3px accent dash, a short heading and one sentence; the level stripe (14x7 segments, always beside its text, filling once to the real level), age and time tags, and ink spots dots. - **Navbar 1 and Footer 7.** The header on the page ground with a hairline once scrolled, the action-fill call to action and an outline call pill; the footer's text rows and credits line, with no call-to-action band. - **The hero.** The treatment for the class finder (`local-class-finder`, `ask`): the promise, the live line, the pill questions, the result card with the one filled trial button, and a single tall photo lane from grid line 12 to the content edge. - **The timetable frame's tokens and the motion tokens.** `--roster-frame`, `-frame-today`, `-frame-edge` and `-frame-rule`; `--roster-rise`, `--roster-fill` and `--roster-spring`, with the platform scroll reveal turned on as a small rise. Every moving thing has a reduced-motion path. - **Checkpoint A geometry.** The header is the mockup's 68px bar (through Navbar 1's `--tome-navbar1-pad-block`), the menu links stand a `--roster-space-5` apart, and at 320px the wordmark, call pill and stripe dots tighten. The result card's spots line sits in the right column beside the button and the link from 1200px, and the hero pads once (`--tome-finder-pad-y: 0`; the wrapper pads). The block marks read the theme's px stripe tokens (`--tome-level-stripe-*`), its 15px small size (`--tome-text-sm`) and its 56px button (`--tome-finder-cta-min-h`), so they win over the blocks' rem fallbacks. - **The stripe fill** animates only the move into `in`; `armed` is instant, so it no longer collapses and overshoots on mount. - **One opt-in effect**, `data-roster-effect="grain"` (Sprout light only). Not in this release: the timetable, pathway, credentials, fee card, team, FAQ, close and program-page treatments, and the hours-aware header. They arrive with the blocks that emit their hooks; `ROSTER_STYLED_BLOCKS` lists only the blocks that are styled.
  • 6131106: Parity fixes against the approved home and program pages, and the treatments for the new optional block fields. - **Measure:** the hero lede and the program lede are the 600px measure (they were 612px). - **Rhythm:** the program page's times list and "Your first class" sit on the tint, so ground and tint alternate down the page. The home page is unchanged. - **Close band:** the portrait and the coach's line start where the form's first field starts, and the taller trial card no longer spreads the right column over its rows. The form's own legend is visually hidden when it has one step. - **Trial form:** fields in two columns from 640px, real checkboxes (a 24px box in the field edge color, ink when chosen, 3:1 or better in every palette), select chevrons and a full-width 56px submit. - **Schedule list:** between 768px and 1023px the "Try it" and "Add to calendar" actions take their own line instead of running past the lane. - **Focus and press:** the focus ring is 3px everywhere (a block's thinner ring is raised), the chosen fee option's ring is the ring color, and a day tab's ring is drawn inside the tab so the scrolling strip cannot cut it. The action pills (hero button, "Try it", fee options and buttons) answer a press with the same spring as the option pills; reduced motion stays still. - **Details:** the coach and pathway age tags use tabular figures, and the stripe no longer touches its words in a pathway teaser. - **New fields:** treatments for the pathway teaser, blurb and age tag, the term strip's next-term cell, the FAQ's second link, a key fact's note tag and the card person's quote.
  • 6534727: The program page's next-sessions list is a card per date, the focus ring is 3px on every pill, and the movers cards no longer outgrow their photos. - **Next-sessions list** (`local-class-schedule`, `range: 'next'`): the block's list rules were as specific as the theme's, so the surface card lost to a hairline row. The theme's rules now carry the block's root attributes and win. A date with one session is one white card with the date over the start time ("Tue 20 · Today" with an ink dot), the name and facts, the spots and the actions; a date with several sessions keeps a card each under its heading. The end time is left to screen readers, and the calendar action is a 44px round icon button when the block sets `calendarStyle: 'icon'`. The list takes the room the term line and controls leave when a block drops them. - **Focus:** the 2px rings the finder (options, call to action, submit, timetable link, selects) and the pathway (band pills, actions) draw one selector deeper than the theme's are raised to `--roster-ring-width`. - **Movers:** the cards use an 8px step, the times sit flush under the blurb and the action's 44px tap box is drawn over the card padding, so the card column ends 29.5px below the photos at 1440px (was 88.8px). - **Card person:** with `personLine: 'inline'` the role follows the name after a comma, its first letter lower-case.
  • e192f5e: Timetable and program-page fixes: lanes, a one-row phone header, a sticky offset that follows the chrome, and compact time, short day names and a today word. - The program page's detail band uses the three page-grid lanes (facts, prose, next-step card); the facts band is four across from 1024px to 1279px and two across on phones. - The phone header (under 768px) is the wordmark and the menu button on one row at every width, open desk or closed; the call lives in the phone bar. - The day strip and the program card stick under what the chrome pins (`--tome-chrome-sticky-height`), so a header that scrolls away leaves no band above them. - A dated day tab is at least 55 by 62px, and the strip scrolls rather than squeeze it below that. - The class time and the level stripe never overlap: the stripe is drawn at the width the time line reserves, and the time can stack as a start over an end (`timeSplit`). Day headings take the short weekday and a "Today" word (`dayNameStyle`, `labels.today`). - Print sits level with the filters from 768px up, wherever the summary wraps.

Theme Studio

v0.1.4
v0.1.4patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.3patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.2patch

c0a665b: The phone menu now styles the platform's new sheet and menu-list hooks (`data-sheet-content`, `data-sheet-overlay`, `data-mobile-menu-list`) instead of reaching into the dialog's structure. The old structural selectors stay beside each hook as a fallback, so the `@wabbit/tome-ui` peer floor is unchanged at 0.21.0 and the menu looks the same on a release without the hooks.

  • c0a665b: The phone menu now styles the platform's new sheet and menu-list hooks (`data-sheet-content`, `data-sheet-overlay`, `data-mobile-menu-list`) instead of reaching into the dialog's structure. The old structural selectors stay beside each hook as a fallback, so the `@wabbit/tome-ui` peer floor is unchanged at 0.21.0 and the menu looks the same on a release without the hooks.
v0.1.1patch

5ae76ee: Safari polish: Title Block cells keep padding beside a rule, the phone menu draws no scrim, the drawing viewer opens fitted, and WebKit cross-fades instead of flashing after the morph. Safari polish round. Title Block cells keep inline padding beside any vertical rule at every width and layout (facts on phones and tablets, credits everywhere); the phone menu draws no scrim; the drawing viewer opens fitted; and WebKit gets a clean cross-fade instead of the morph, whose destination image it does not paint during the transition (the flash after the morph on iPhone). Theme CSS only. The morph is unchanged in Chromium.

  • 5ae76ee: Safari polish: Title Block cells keep padding beside a rule, the phone menu draws no scrim, the drawing viewer opens fitted, and WebKit cross-fades instead of flashing after the morph. Safari polish round. Title Block cells keep inline padding beside any vertical rule at every width and layout (facts on phones and tablets, credits everywhere); the phone menu draws no scrim; the drawing viewer opens fitted; and WebKit gets a clean cross-fade instead of the morph, whose destination image it does not paint during the transition (the flash after the morph on iPhone). Theme CSS only. The morph is unchanged in Chromium.
v0.1.0minor

757ef79: New theme package `@wabbit/tome-theme-studio` for studios whose portfolio is the sales argument. It is a new package, so the minor bump is purely additive. The first release ships four palettes (Gallery, Concrete, Black Box and Blueprint) with their derived opposite modes, and Archivo at three widths as a self-hosted OFL subset that keeps `tnum`. The first treatments cover the base, the section opener with its counter, the Title Block cells, the Navbar 1 header, the home hero (`work-index` layout `feature`) and the ledger (`work-index` layout `index`), plus the motion tokens with their reduced-motion paths. The accent is a mark and never text, and type never sits on the work. The home-page treatments follow, all through public hooks: - The Work Index tools: the filter chips with their ink label and muted count, the live count line, the Plates and Ledger toggle with its 44px hit areas, the phone Filter button and the full-height filter sheet. - The Plates view with one frame per palette: Gallery's wall-colored mat, Black Box's bare images, Concrete's edge-to-edge tiles and Blueprint's 1px keyline. - Selected work, with its three-cell Title Block, its credits and the straddle (the last plate overhangs into the next band, which takes the overhang at its top). - The practice statement, the service rows, the pinned process (the dossier seams mapped to the theme's pin offset, gap and mark), people, and the clients' names with their quote. - The close as the one dark band, re-pointing both the Layer-1 and the Layer-2 color names to the palette's band family, and Footer 7 on its own tint zone. - The numbered, type-only phone menu, the centered header layout and the current-page hook. The case study is styled too: - `project-hero` is the opener and the lead plate (breadcrumb, the 21:9 lead with its 4:5 phone crop and one settle, the plate line, the h1 at the h2 size, the facts line and "About the project"), with nothing laid over the photograph. - `plate-sequence` styles all three modes with margin captions pinned at `--studio-pin-top` (mapped to the pack's `--tome-plate-pin-top`), the numbered count line with its square mark, credits as the renderer prints them, and the drawings on the surface color in a 1px frame that takes the palette's ink. - The drawing viewer is a full-screen native dialog with 44px open and close controls, a bar in the meta voice, and a scrolling region that draws its focus ring inside itself. - The `title-block` credits, brief and facts bands, the `work-index` `next` teaser (image first, ink and outline buttons), and the `testimonial` client voice (upright, at display width, on the tint) join the treatments. - The morph CSS classes every project image with `view-transition-class` and times the `tome-project-*` groups and the root cross-fade from `--studio-morph`. Reduced motion switches every view-transition animation off. The theme declares no `view-transition-name` and leaves `@view-transition` to tome-motion's `RouteTransition`. Fixes after the first browser pass against the approved home and case study: - The pinned process lays its plates one per row from 1024 (the pack's phone grid and plate 1's two-track span are reset), in the approved columns (list 1 to 8, plates 10 to 16), and the active phase steps 1 to 5. - Clients uses the pack's `heading` field; the names align left in their cells, the quote's marks are drawn, and a ledger row used as the heading stays on one line. - The footer reads on the tint in all four palettes (and their dark modes), keeps a newline in the subline, styles the new brand rows, and puts the copyright before the credits line. - The straddle's statement takes the overhang plus its own rhythm, so the plate's credit sits clear of its heading; the statement styles the new sample note. - A new `treatments/targets.css` makes the header links, the name, ledger and service titles, a service's representative project, Plates tile titles, a facts cell's link, a footer line's link and narrow crumbs 44px targets without moving the layout. - The work heading, the ledger and service titles, the chips and the count line share one text edge; the facts cell is 66.5px; passage and About paragraphs have a gap; the crumb slashes center on their links; the close band's heading clears its paragraph by 24px and a one-step form's legend is visually hidden. - The pinned offset follows the header: while the header is hidden the process list and plate captions pin at one step from the top, and ride back down when it returns.

  • 757ef79: New theme package `@wabbit/tome-theme-studio` for studios whose portfolio is the sales argument. It is a new package, so the minor bump is purely additive. The first release ships four palettes (Gallery, Concrete, Black Box and Blueprint) with their derived opposite modes, and Archivo at three widths as a self-hosted OFL subset that keeps `tnum`. The first treatments cover the base, the section opener with its counter, the Title Block cells, the Navbar 1 header, the home hero (`work-index` layout `feature`) and the ledger (`work-index` layout `index`), plus the motion tokens with their reduced-motion paths. The accent is a mark and never text, and type never sits on the work. The home-page treatments follow, all through public hooks: - The Work Index tools: the filter chips with their ink label and muted count, the live count line, the Plates and Ledger toggle with its 44px hit areas, the phone Filter button and the full-height filter sheet. - The Plates view with one frame per palette: Gallery's wall-colored mat, Black Box's bare images, Concrete's edge-to-edge tiles and Blueprint's 1px keyline. - Selected work, with its three-cell Title Block, its credits and the straddle (the last plate overhangs into the next band, which takes the overhang at its top). - The practice statement, the service rows, the pinned process (the dossier seams mapped to the theme's pin offset, gap and mark), people, and the clients' names with their quote. - The close as the one dark band, re-pointing both the Layer-1 and the Layer-2 color names to the palette's band family, and Footer 7 on its own tint zone. - The numbered, type-only phone menu, the centered header layout and the current-page hook. The case study is styled too: - `project-hero` is the opener and the lead plate (breadcrumb, the 21:9 lead with its 4:5 phone crop and one settle, the plate line, the h1 at the h2 size, the facts line and "About the project"), with nothing laid over the photograph. - `plate-sequence` styles all three modes with margin captions pinned at `--studio-pin-top` (mapped to the pack's `--tome-plate-pin-top`), the numbered count line with its square mark, credits as the renderer prints them, and the drawings on the surface color in a 1px frame that takes the palette's ink. - The drawing viewer is a full-screen native dialog with 44px open and close controls, a bar in the meta voice, and a scrolling region that draws its focus ring inside itself. - The `title-block` credits, brief and facts bands, the `work-index` `next` teaser (image first, ink and outline buttons), and the `testimonial` client voice (upright, at display width, on the tint) join the treatments. - The morph CSS classes every project image with `view-transition-class` and times the `tome-project-*` groups and the root cross-fade from `--studio-morph`. Reduced motion switches every view-transition animation off. The theme declares no `view-transition-name` and leaves `@view-transition` to tome-motion's `RouteTransition`. Fixes after the first browser pass against the approved home and case study: - The pinned process lays its plates one per row from 1024 (the pack's phone grid and plate 1's two-track span are reset), in the approved columns (list 1 to 8, plates 10 to 16), and the active phase steps 1 to 5. - Clients uses the pack's `heading` field; the names align left in their cells, the quote's marks are drawn, and a ledger row used as the heading stays on one line. - The footer reads on the tint in all four palettes (and their dark modes), keeps a newline in the subline, styles the new brand rows, and puts the copyright before the credits line. - The straddle's statement takes the overhang plus its own rhythm, so the plate's credit sits clear of its heading; the statement styles the new sample note. - A new `treatments/targets.css` makes the header links, the name, ledger and service titles, a service's representative project, Plates tile titles, a facts cell's link, a footer line's link and narrow crumbs 44px targets without moving the layout. - The work heading, the ledger and service titles, the chips and the count line share one text edge; the facts cell is 66.5px; passage and About paragraphs have a gap; the crumb slashes center on their links; the close band's heading clears its paragraph by 24px and a one-step form's legend is visually hidden. - The pinned offset follows the header: while the header is hidden the process list and plate captions pin at one step from the top, and ride back down when it returns.

Theme Commons

v0.1.3
v0.1.3patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v0.1.2patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.1patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.0minor

e473a44: New theme package `@wabbit/tome-theme-commons` for organizations that belong to their members: neighborhood associations, tenant and parent groups, co-ops, community halls, clubs and small nonprofits. It is a new package, so the minor bump is purely additive. The first release ships four palettes with their derived dark modes: Grove (the default; a bottle-glass ink, with a lantern amber that fills only Give and the Running Total bar), Civic, Vesper and Rally (whose closing band is the red fill in both modes). Fonts are Schibsted Grotesk (display, the record line and every numeral) and Inclusive Sans (body), self-hosted as OFL subsets that keep `tnum` and `pnum` on Schibsted. The first treatments cover: - the base and the record-line voice, with the pills (Give is the fill, Join the outline); - the section opener's masthead line; - the Navbar 1 header; - the `campaign-hero` `portrait` hero, with its photograph from the page edge and its label and credit in a caption after the image; - the Running Total (the line, the flat bar and the Ledger Strip), with the bar mapped onto campaign-pack's own custom properties; - the sourced figures ledger (`stat` `sourced`); - the close as the one dark band, with the Give form and the Card inside it; - the band rhythm and the tint: the opener's gap and the section rhythm on the block wrappers, the events band and the Record on the tint, and tags on the tint stepping to the surface; - programs (`division-card` `entry`: the photo's side alternating by grid placement, the lead line, the next date under a hairline, the caption after the image); - the updates and the Record (`archive` `filed`: a lead and a ruled list; a register with the record number, the category as a tint wash, the dates, the file size and a Download action, the latest filing led by its thumbnail), and the outline "read the full record" link after the register; - events (`event-list`: dated ruled rows, capacity chips where there is capacity data and a 2px border on a full event, "Add to calendar"), and the next board meeting stacked in its governance cell; - people (`team-roster` `people`: 4:5 portraits in one tonal treatment with the label below the photo, the role in the record voice, the statement tile on a 1px hairline with no top bar) and the governance grid under it; - the FAQ with its "Still have a question?" card (the callout, on a neutral hairline), the footer's tonal zone (Footer 7), the phone menu (no scrim, the same Give and Join pills) and the 44px targets for all of them; - the Give form root filling its wrapper outside the close. Checkpoint A fixes against the approved round 2: - the close lays out on the page grid: its layoutGrid gives up the extras size container (layout containment made `subgrid` compute to `none`), its text-blocks' body and links span their root, the close's Running Total line runs the 600px measure, the band re-points `--tome-color-surface` and `--tome-color-on-surface` (the give form's and the Card's inks), and the reply link has a 44px target; - the figures strip places its label cell and cells under the sourced root, out-ranking the pack's own wide-screen placement, so the four cells run from the reading line from 1024px; - the give form's control wrappers dissolve into their fieldset's grid (the segmented control and the chips fill the form), an empty error region draws no frame, and the Other input loses its second outline; - the Card leaves its tiers' and picker's display to the block (one tier under the picker, opening on Household) and keeps its price at the face's start; - the home hero sits on the page ground and, below 1024px, its photo row leads (grid rows; the copy stays first in the reading order); - the hero eyebrow's record number takes the record-number color; - the volunteer form's Send is the outline pill (the lantern fills only Give and the bar); - a layoutGrid other than the close (Ways in, governance) stacks below 1024px. The recommended Card tier is a 2px ink border all round. No Commons rule draws a colored top edge, a badge or a bar on a card. Wave 2b treatments, the campaign page and the single-task flows: - the case for a campaign (`campaign-case`: the reading column with marginalia, each note an ink hairline with its title in the record voice, the budget as ruled lines with its computed total in the heavy weight, the margins folding into one tinted region below 1024px); - giving levels (`campaign-tiers` `giving-levels`: columns of the page under an ink hairline, the price at the Card's numeral size, the action as an outline pill on the bottom line, no recommended level and no box, one column, two from 640px, the level count from 1024px); - the terms ledger (`campaign-terms` `ledger`: the keep statement with its one run at the heavy weight, five or six terms as ruled definition rows, the filing line in the record voice); - Ways in as three equal doors (the volunteer form, Join and Give: the ask in the record voice, the action on the bottom line, the form's repeated step legend visually hidden, the Send and the two links as outline pills, labels with 44px targets), the opt-in sign-up sheet (`data-commons-texture="signup-sheet"` on `<html>`: faint ruled rows with numbers in the gutter behind the doors only, never on the dark band, no numbers under 640px) and "What we hold to" (extras `content` on the tint, the heading in the start lane and the prose in the reading column); - chrome's checkout mode: the brand header with a hairline-divided label and "Back to <name>", wrapping to two rows under 640px (the real bar is one nowrap row), the plain footer on the footer zone's tint, and the lockup (`data-commons-lockup`, a mark and the name in a row), which Wave 1 left unstyled; - the Give page (the context, the Running Total and the form as a panel on the card surface, side by side from 1024px) and the Join page (the wide Card on the content lines, the block's repeated heading visually hidden), every single-task h1 at the heavy h2 step, and the thank-you pages' numbered rows. The close's rules now exclude a layoutGrid that holds the page's own h1, so the Give page, which also holds a give form, is not painted as the dark band. The hook-coverage later-wave list is empty. Contract hooks without a part hook (the Ways-in text blocks, the form intro, `content`, the thank-you rows) are reached by position through a documented allow-list in the structural-read test. Checkpoint B fixes against round 2 and the approved Give flow: - the Give page: a paragraph before the page's h1 is the record line (the record voice, in the ink), and a text block after the form is the reply line, under the context from 1024px and after the form on a phone, its link a 44px target; the give form's opt-in lines (the amount hint, the fee-cover total over the button, the monthly charge line under it, the larger-gift line in the error region, whose hidden state no longer draws the empty error frame); - the Ledger Strip: the bar takes the lantern fill and the deep track (campaign-pack's strip now reads the bar properties Commons already set), its "Demo figures" tag steps to the surface on the tint, and under the campaign page's own hero the strip takes round 2's tighter rhythm; - the case: the budget total in proportional figures (no tabular comma gap), and below 1024px the reading column starts at the content edge instead of centering; - the giving levels sit on the tint with their opener (round 2's alternation), one gap under the opener, with each level's sentence at the small size; - the campaign updates (no kicker, no lead, no downloads) set the heading and its sentence in the start lane and the list in the reading column from 1024px, and a row's author line follows the number after a middle dot; - the header keeps Give and Join below 1024px: one row (the lockup, Give and Join, Menu) from 560px, and two rows under it (the lockup and Menu, then Give and Join sharing the width), placed by grid area; - 44px targets for a text block's own link row ("Write to Tom") and a linked hero heading ("The roof and the boiler").

  • e473a44: New theme package `@wabbit/tome-theme-commons` for organizations that belong to their members: neighborhood associations, tenant and parent groups, co-ops, community halls, clubs and small nonprofits. It is a new package, so the minor bump is purely additive. The first release ships four palettes with their derived dark modes: Grove (the default; a bottle-glass ink, with a lantern amber that fills only Give and the Running Total bar), Civic, Vesper and Rally (whose closing band is the red fill in both modes). Fonts are Schibsted Grotesk (display, the record line and every numeral) and Inclusive Sans (body), self-hosted as OFL subsets that keep `tnum` and `pnum` on Schibsted. The first treatments cover: - the base and the record-line voice, with the pills (Give is the fill, Join the outline); - the section opener's masthead line; - the Navbar 1 header; - the `campaign-hero` `portrait` hero, with its photograph from the page edge and its label and credit in a caption after the image; - the Running Total (the line, the flat bar and the Ledger Strip), with the bar mapped onto campaign-pack's own custom properties; - the sourced figures ledger (`stat` `sourced`); - the close as the one dark band, with the Give form and the Card inside it; - the band rhythm and the tint: the opener's gap and the section rhythm on the block wrappers, the events band and the Record on the tint, and tags on the tint stepping to the surface; - programs (`division-card` `entry`: the photo's side alternating by grid placement, the lead line, the next date under a hairline, the caption after the image); - the updates and the Record (`archive` `filed`: a lead and a ruled list; a register with the record number, the category as a tint wash, the dates, the file size and a Download action, the latest filing led by its thumbnail), and the outline "read the full record" link after the register; - events (`event-list`: dated ruled rows, capacity chips where there is capacity data and a 2px border on a full event, "Add to calendar"), and the next board meeting stacked in its governance cell; - people (`team-roster` `people`: 4:5 portraits in one tonal treatment with the label below the photo, the role in the record voice, the statement tile on a 1px hairline with no top bar) and the governance grid under it; - the FAQ with its "Still have a question?" card (the callout, on a neutral hairline), the footer's tonal zone (Footer 7), the phone menu (no scrim, the same Give and Join pills) and the 44px targets for all of them; - the Give form root filling its wrapper outside the close. Checkpoint A fixes against the approved round 2: - the close lays out on the page grid: its layoutGrid gives up the extras size container (layout containment made `subgrid` compute to `none`), its text-blocks' body and links span their root, the close's Running Total line runs the 600px measure, the band re-points `--tome-color-surface` and `--tome-color-on-surface` (the give form's and the Card's inks), and the reply link has a 44px target; - the figures strip places its label cell and cells under the sourced root, out-ranking the pack's own wide-screen placement, so the four cells run from the reading line from 1024px; - the give form's control wrappers dissolve into their fieldset's grid (the segmented control and the chips fill the form), an empty error region draws no frame, and the Other input loses its second outline; - the Card leaves its tiers' and picker's display to the block (one tier under the picker, opening on Household) and keeps its price at the face's start; - the home hero sits on the page ground and, below 1024px, its photo row leads (grid rows; the copy stays first in the reading order); - the hero eyebrow's record number takes the record-number color; - the volunteer form's Send is the outline pill (the lantern fills only Give and the bar); - a layoutGrid other than the close (Ways in, governance) stacks below 1024px. The recommended Card tier is a 2px ink border all round. No Commons rule draws a colored top edge, a badge or a bar on a card. Wave 2b treatments, the campaign page and the single-task flows: - the case for a campaign (`campaign-case`: the reading column with marginalia, each note an ink hairline with its title in the record voice, the budget as ruled lines with its computed total in the heavy weight, the margins folding into one tinted region below 1024px); - giving levels (`campaign-tiers` `giving-levels`: columns of the page under an ink hairline, the price at the Card's numeral size, the action as an outline pill on the bottom line, no recommended level and no box, one column, two from 640px, the level count from 1024px); - the terms ledger (`campaign-terms` `ledger`: the keep statement with its one run at the heavy weight, five or six terms as ruled definition rows, the filing line in the record voice); - Ways in as three equal doors (the volunteer form, Join and Give: the ask in the record voice, the action on the bottom line, the form's repeated step legend visually hidden, the Send and the two links as outline pills, labels with 44px targets), the opt-in sign-up sheet (`data-commons-texture="signup-sheet"` on `<html>`: faint ruled rows with numbers in the gutter behind the doors only, never on the dark band, no numbers under 640px) and "What we hold to" (extras `content` on the tint, the heading in the start lane and the prose in the reading column); - chrome's checkout mode: the brand header with a hairline-divided label and "Back to <name>", wrapping to two rows under 640px (the real bar is one nowrap row), the plain footer on the footer zone's tint, and the lockup (`data-commons-lockup`, a mark and the name in a row), which Wave 1 left unstyled; - the Give page (the context, the Running Total and the form as a panel on the card surface, side by side from 1024px) and the Join page (the wide Card on the content lines, the block's repeated heading visually hidden), every single-task h1 at the heavy h2 step, and the thank-you pages' numbered rows. The close's rules now exclude a layoutGrid that holds the page's own h1, so the Give page, which also holds a give form, is not painted as the dark band. The hook-coverage later-wave list is empty. Contract hooks without a part hook (the Ways-in text blocks, the form intro, `content`, the thank-you rows) are reached by position through a documented allow-list in the structural-read test. Checkpoint B fixes against round 2 and the approved Give flow: - the Give page: a paragraph before the page's h1 is the record line (the record voice, in the ink), and a text block after the form is the reply line, under the context from 1024px and after the form on a phone, its link a 44px target; the give form's opt-in lines (the amount hint, the fee-cover total over the button, the monthly charge line under it, the larger-gift line in the error region, whose hidden state no longer draws the empty error frame); - the Ledger Strip: the bar takes the lantern fill and the deep track (campaign-pack's strip now reads the bar properties Commons already set), its "Demo figures" tag steps to the surface on the tint, and under the campaign page's own hero the strip takes round 2's tighter rhythm; - the case: the budget total in proportional figures (no tabular comma gap), and below 1024px the reading column starts at the content edge instead of centering; - the giving levels sit on the tint with their opener (round 2's alternation), one gap under the opener, with each level's sentence at the small size; - the campaign updates (no kicker, no lead, no downloads) set the heading and its sentence in the start lane and the list in the reading column from 1024px, and a row's author line follows the number after a middle dot; - the header keeps Give and Join below 1024px: one row (the lockup, Give and Join, Menu) from 560px, and two rows under it (the lockup and Menu, then Give and Join sharing the width), placed by grid area; - 44px targets for a text block's own link row ("Write to Tom") and a linked hero heading ("The roof and the boiler").

Theme Grounds

v0.1.4
v0.1.4patch

49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.

  • 49eb91d: Canceled statuses now accept both spellings. This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet. Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American `canceled` (`CANCELED` on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (`STATUS:CANCELLED` is unchanged, it is the iCalendar literal), the status board badge, theme selectors for `data-event-status` and the erasure trail treat the two spellings as one meaning. The event list board labels gain a `canceled` key that is read before the old `cancelled` key. The old field is hidden, not removed, so stored text is kept on save. The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)". Writers still store the old values.
v0.1.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.2patch

23c0f86: The event page's locator shows its "See Field 4 on the full plan" link when the link goes to the full plan on another page. The locator card hid "Show on plan" outright, because it used to point at the card's own marker. It now hides only a link to an in-page marker, and a link to the full plan elsewhere (local-facilities' `showOnPlanHrefTemplate`) shows as the card's last outlined action, as the event page design draws it.

  • 23c0f86: The event page's locator shows its "See Field 4 on the full plan" link when the link goes to the full plan on another page. The locator card hid "Show on plan" outright, because it used to point at the card's own marker. It now hides only a link to an in-page marker, and a link to the full plan elsewhere (local-facilities' `showOnPlanHrefTemplate`) shows as the card's last outlined action, as the event page design draws it.
  • e591314: The README's "What is in this release" now describes what 0.1.1 ships. It said the block treatments would come "in the next wave", though 0.1.0 shipped them. It now lists the four palettes, the 15 styled blocks, the companion packs the demos are built from, and how the Today Board treats a closure once it ends.
v0.1.1patch

2f64106: The theme's manifest lists the blocks it restyles.

  • 2f64106: The theme's manifest lists the blocks it restyles.
v0.1.0minor

6e084f0: First release of Grounds, a Tome theme for places people visit: parks, sports grounds, venues, market halls and districts. Grounds puts the visitor first: what is on today, where it is on the grounds, and how to get there, then how to book. Four palettes, each light and dark: Pennant (indigo sign, coral flag, the default), Trailhead (bronze and blaze blue), Lido (aqua and violet) and Marquee (claret and moss). Type is Overpass for headings, with figures that line up for times and scores, and Figtree for reading; both are self-hosted. It styles the Today Board (live, starting and finished events, said in words as well as color), the facilities guide with its illustrated site plan and the phone plan sheet, the dismissible alert, the venue hero with up to four visitor doors and a photo credit, the Upcoming lead event, program cards with plan links, the tenant directory and promotions rail, the booking desk (including its errors when a visitor posts without JavaScript), the numbers, voices, questions and footer bands, and the phone bar with Directions and Book.

  • 6e084f0: First release of Grounds, a Tome theme for places people visit: parks, sports grounds, venues, market halls and districts. Grounds puts the visitor first: what is on today, where it is on the grounds, and how to get there, then how to book. Four palettes, each light and dark: Pennant (indigo sign, coral flag, the default), Trailhead (bronze and blaze blue), Lido (aqua and violet) and Marquee (claret and moss). Type is Overpass for headings, with figures that line up for times and scores, and Figtree for reading; both are self-hosted. It styles the Today Board (live, starting and finished events, said in words as well as color), the facilities guide with its illustrated site plan and the phone plan sheet, the dismissible alert, the venue hero with up to four visitor doors and a photo credit, the Upcoming lead event, program cards with plan links, the tenant directory and promotions rail, the booking desk (including its errors when a visitor posts without JavaScript), the numbers, voices, questions and footer bands, and the phone bar with Directions and Book.

Theme Schoolroom

v0.1.3
v0.1.3patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.2patch

1bc827f: Fixes the offset gallery: the two small photographs now sit beside the large one instead of in a sliver over it. The pack lays the offset out by named grid areas from 48rem; the theme placed the photographs by lines but left the pack's areas in place, so from 768px the small photographs fell into an implicit column a few pixels wide over the large one. The theme now clears `grid-template-areas`, resets the pack's area placement, and places the photographs through the parent so its rules out-rank the pack's `:nth-child` placement. A test pins both.

  • 1bc827f: Fixes the offset gallery: the two small photographs now sit beside the large one instead of in a sliver over it. The pack lays the offset out by named grid areas from 48rem; the theme placed the photographs by lines but left the pack's areas in place, so from 768px the small photographs fell into an implicit column a few pixels wide over the large one. The theme now clears `grid-template-areas`, resets the pack's area placement, and places the photographs through the parent so its rules out-rank the pack's `:nth-child` placement. A test pins both.
v0.1.1patch

d400df3: Corrects the package notes: 0.1.0 shipped the three treatments, not only the foundation. The README's "What is in this release" and the 0.1.0 changelog entry said the block, chrome and learner-app treatments would follow in the next release. They were in 0.1.0: `styledBlocks` lists the seventeen restyled blocks, and the marketing, player and learner stylesheets ship in the package. This release changes the notes and no CSS.

  • d400df3: Corrects the package notes: 0.1.0 shipped the three treatments, not only the foundation. The README's "What is in this release" and the 0.1.0 changelog entry said the block, chrome and learner-app treatments would follow in the next release. They were in 0.1.0: `styledBlocks` lists the seventeen restyled blocks, and the marketing, player and learner stylesheets ship in the package. This release changes the notes and no CSS.
v0.1.0minor

0f8d412: First release of Schoolroom, a Tome theme for people who teach for a living and want their school to look like a school, not a funnel. This first cut is the foundation: four palettes, each light and dark (Margin, the default, in graphite with a highlighter yellow; Lab in ink-pen violet; Atelier in magenta; Night School in chalk blue, which opens dark), the passed, needs-work and expired colors as tokens that mean the same thing in every palette, and a roomy marketing width with a learner-app width the lesson player and dashboard follow. Type is Literata for headings, reading and every column of figures, and Lexend for the interface; both are self-hosted. It also assigns the colors the learner UI leaves open for a theme, so passed rows, locked lessons and expired certificates take the palette's own colors. The block, chrome and learner-app treatments follow in the next release.

  • 0f8d412: First release of Schoolroom, a Tome theme for people who teach for a living and want their school to look like a school, not a funnel. This first cut is the foundation: four palettes, each light and dark (Margin, the default, in graphite with a highlighter yellow; Lab in ink-pen violet; Atelier in magenta; Night School in chalk blue, which opens dark), the passed, needs-work and expired colors as tokens that mean the same thing in every palette, and a roomy marketing width with a learner-app width the lesson player and dashboard follow. Type is Literata for headings, reading and every column of figures, and Lexend for the interface; both are self-hosted. It also assigns the colors the learner UI leaves open for a theme, so passed rows, locked lessons and expired certificates take the palette's own colors. The block, chrome and learner-app treatments follow in the next release.

Theme Rounds

v0.1.2
v0.1.2patch

807c889: Practice sites can now build a full first-visit page and a provider index from blocks. All additive: existing blocks render as before unless a page selects the new options. - `practice-steps` adds a `long` variant: details and a how-long and who panel per step. - `practice-people-list` adds a `directory` variant: labeled facts and one action along the card bottoms. - `practice-record-header` adds a typed `page` variant for a page with no record behind it. - `practice-book` adds a typed `bookLink` for a page with no person behind it. - `proof-plates` plates can carry a checklist. - The Rounds theme styles all of these. Its closing book block now sets the words beside stacked choices from 1024px, the caption over the words, and the current nav link is marked.

  • 807c889: Practice sites can now build a full first-visit page and a provider index from blocks. All additive: existing blocks render as before unless a page selects the new options. - `practice-steps` adds a `long` variant: details and a how-long and who panel per step. - `practice-people-list` adds a `directory` variant: labeled facts and one action along the card bottoms. - `practice-record-header` adds a typed `page` variant for a page with no record behind it. - `practice-book` adds a typed `bookLink` for a page with no person behind it. - `proof-plates` plates can carry a checklist. - The Rounds theme styles all of these. Its closing book block now sets the words beside stacked choices from 1024px, the caption over the words, and the current nav link is marked.
v0.1.1patch

9816a1a: Fixes the "things you can check" tiles, which had never rendered with content: the heading and intro now line up with the tiles on the content's left edge, the tiles lose the pack's border, and each tile's link gets its own line.

  • 9816a1a: Fixes the "things you can check" tiles, which had never rendered with content: the heading and intro now line up with the tiles on the content's left edge, the tiles lose the pack's border, and each tile's link gets its own line.
v0.1.0minor

717c0cf: First release of Rounds, a Tome theme for health and wellness practices where every colored section is a rounded card. It is made for health and wellness practices, counseling and group practices included, where a new patient should see at once who is treating them. Four palettes, each light and dark: Morning (the default, a cool pale ground with a deep sea-teal field), Blush (warm rose and wine), Stride (olive and chartreuse, for movement and rehabilitation) and Dusk (blue-violet, which opens dark, for counseling). Type is Gabarito for headings and the large phone number and Instrument Sans for everything else; both are self-hosted, and both have tabular figures, so phone numbers, prices and times line up. It styles the practice pack (the desk strip, the need picker that works without JavaScript, the connected first-visit steps, an even row of people, the offices beside a map, the reviewed line and the closing book-or-call field), the local pack's status and locations blocks, the two-tier header with its live status strip and large phone number, the photo hero, and the footer as its own card. Motion is one reveal, played once, and reduced motion turns it off. Every contrast pair clears 4.5:1 and every target is at least 44px.

  • 717c0cf: First release of Rounds, a Tome theme for health and wellness practices where every colored section is a rounded card. It is made for health and wellness practices, counseling and group practices included, where a new patient should see at once who is treating them. Four palettes, each light and dark: Morning (the default, a cool pale ground with a deep sea-teal field), Blush (warm rose and wine), Stride (olive and chartreuse, for movement and rehabilitation) and Dusk (blue-violet, which opens dark, for counseling). Type is Gabarito for headings and the large phone number and Instrument Sans for everything else; both are self-hosted, and both have tabular figures, so phone numbers, prices and times line up. It styles the practice pack (the desk strip, the need picker that works without JavaScript, the connected first-visit steps, an even row of people, the offices beside a map, the reviewed line and the closing book-or-call field), the local pack's status and locations blocks, the two-tier header with its live status strip and large phone number, the photo hero, and the footer as its own card. Motion is one reveal, played once, and reduced motion turns it off. Every contrast pair clears 4.5:1 and every target is at least 44px.

Ai

v0.4.5
v0.4.5patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.4.4patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.4.3patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.4.2patch

3dd42b8: A signed-in non-admin can no longer create or reassign an AI credential under another member's `owner`. The `ai-credentials` collection now pins `owner` in a `beforeValidate` hook: a non-admin's create is always owned by `req.user` (an omitted `owner` is filled in), and a non-admin update cannot change `owner`. Admins and Local API calls with no `req.user` are unaffected. Read, update and delete stay owner-or-admin.

  • 3dd42b8: A signed-in non-admin can no longer create or reassign an AI credential under another member's `owner`. The `ai-credentials` collection now pins `owner` in a `beforeValidate` hook: a non-admin's create is always owned by `req.user` (an omitted `owner` is filled in), and a non-admin update cannot change `owner`. Admins and Local API calls with no `req.user` are unaffected. Read, update and delete stay owner-or-admin.
v0.4.1patch

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.

  • 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.
  • 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.
v0.4.0minor

68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognizes the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behavior change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.

  • 68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognizes the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behavior change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.
v0.3.0minor

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

Print

v0.1.8
v0.1.8patch

deea397: The changelog, README and license text now use American spellings. No code or behavior changes.

  • deea397: The changelog, README and license text now use American spellings. No code or behavior changes.
v0.1.7patch

d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.

  • d51ad89: README: subpath and file mentions are written package-qualified (for example `@wabbit/tome-x/render`) or as code text, so crawlers no longer resolve them as site URLs.
v0.1.6patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behavior change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
v0.1.5patch

7eda51f: The `tome-print` CLI now opens with a `#!/usr/bin/env node` shebang, so the bin starts on macOS and Linux instead of being handed to the shell.

  • 7eda51f: The `tome-print` CLI now opens with a `#!/usr/bin/env node` shebang, so the bin starts on macOS and Linux instead of being handed to the shell.
  • d9d0079: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata. `injectCharts` now ships a neutral fictional sample dataset in place of one engagement's figures; its section-heading rules for the projection and launch charts now match "What this means going forward" and "How the launch result was built".
v0.1.4patch

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.

  • 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.
  • 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.
v0.1.3patch

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: 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.
v0.1.2patch

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.
v0.1.1patch

233c45e: Add tsup build pipeline: emit ESM+CJS+DTS to `dist/`, rewrite `package.json` exports/main/module/types to point at `dist`, copy CSS assets (report.css + fonts/) via standalone post-build script, expose CLI bin entry. Fixes bundler-incompatible raw `.ts` source distribution.

  • 233c45e: Add tsup build pipeline: emit ESM+CJS+DTS to `dist/`, rewrite `package.json` exports/main/module/types to point at `dist`, copy CSS assets (report.css + fonts/) via standalone post-build script, expose CLI bin entry. Fixes bundler-incompatible raw `.ts` source distribution.

See what we build with this