Changelog

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

Last generated October 11, 2026.

Core

v1.21.1
v1.21.1patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 colour groups. `'light-first'` keeps the classic `regularColors`/`darkmodeColors` field names, so existing documents need no migration. Colour 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 colour groups. `'light-first'` keeps the classic `regularColors`/`darkmodeColors` field names, so existing documents need no migration. Colour 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. Behaviour 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. **Behaviour 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 licence 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 behaviour is unchanged. The functions only read the list. `checkRoleAsync` and `checkRole` stay `@deprecated` in favour 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 behaviour is unchanged. The functions only read the list. `checkRoleAsync` and `checkRole` stay `@deprecated` in favour 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 behaviours 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-normalising `beforeValidate` hook across verbatim; normalising 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-normalising `beforeValidate` hook across verbatim; normalising 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 behaviour 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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 behaviour. `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 favour 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 honours 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 honours 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 characterised `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 behaviour 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 behaviour 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 — normalised 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 — normalised 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, behaviour 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, behaviour 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.1.1
v0.1.1patch

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

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

Ui

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 colour 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 colours. `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 colour 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 colours. `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 colour 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 colour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
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.0
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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
  • 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 favour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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.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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

  • 6530765: Removed an unused CSS-copy build hook (this package ships no CSS of its own); no behaviour 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.0
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 centred line and caption, instead of centred 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 centring, width, padding and vertical centring, and the band, scene image and caption are unchanged. - **Its link colour** is now declared on the CTA itself as `var(--tome-color-primary)`, the colour 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 coloured 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 centred line and caption, instead of centred 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 centring, width, padding and vertical centring, and the band, scene image and caption are unchanged. - **Its link colour** is now declared on the CTA itself as `var(--tome-color-primary)`, the colour 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 coloured 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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

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

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

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
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`. Behaviour 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`. Behaviour 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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
  • 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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.35.0
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 labelled 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 labelled 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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
  • 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 behaviour 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 behaviour 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 colour and layout, and stay because stored documents carry them.
v0.18.0patch

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behaviour 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`. Behaviour 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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
  • 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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 no paid `feature-pro` — flag if a paid feature tier is wanted. 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.35.0
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 labelled 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 labelled 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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

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

c3468b0: `register()` is now built with blocks-core's `createPackRegistrar`. Behaviour 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`. Behaviour 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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
  • 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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.28.5
v0.28.5patch

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

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
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 favour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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.35.0
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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
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`. Behaviour 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`. Behaviour 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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
  • 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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.28.5
v0.28.5patch

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

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

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

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
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 colour), 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 colour), 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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.14.0
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 labelled 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 labelled 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 colour; the lit row keeps its inverted colours. 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 colour; the lit row keeps its inverted colours. 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). Behaviour 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). Behaviour 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 colour 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 colour. 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 colour 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 colour. 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 centre 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 centring 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 centred 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 colours 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 colour rules now use two-class selectors. The colours 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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

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

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

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
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 behaviour 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 behaviour 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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
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 behaviour 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 behaviour) 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 behaviour 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 behaviour) 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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 no paid `feature-pro` — flag if a paid feature tier is wanted. 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.1
v0.2.1patch

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

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

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

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

Longform

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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, colours, 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 colour (operation red, intel blue, codex gold, external grey) 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 centred. - Footnotes: a hairline sits above the notes. - Spoiler: the reveal control sits over the hidden content, centred, 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. Behaviour 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, colours, 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 colour (operation red, intel blue, codex gold, external grey) 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 centred. - Footnotes: a hairline sits above the notes. - Spoiler: the reveal control sits over the hidden content, centred, 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. Behaviour 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 behaviour 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 behaviour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
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.0
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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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, colours 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 colour and sits at the left, followed by the caption and a full-width bar standing for the withheld text (it was a centred outlined stamp over the caption). - Redacted `IMAGE`: the placeholder takes the classification colour 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, colours 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 colour and sits at the left, followed by the caption and a full-width bar standing for the withheld text (it was a centred outlined stamp over the caption). - Redacted `IMAGE`: the placeholder takes the classification colour 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 behaviour 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 behaviour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
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.0
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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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, colours, 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 labelled 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, colours, 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 labelled 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 colours, 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 colours, 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 behaviour 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 behaviour 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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
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.9.0
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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
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 behaviour 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 favour 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.3
v0.2.3patch

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

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

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

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

  • 0d925a4: Counsel v1 chrome and forms hooks. Every new field is optional, and existing output is unchanged when it is empty. **Migration: the new Payload fields add columns and tables.** Sites that register the Footer global, the `forms` collection or the `tomeForm` block must run their Payload migration (`payload migrate:create`, then `payload migrate`) and regenerate types (`payload generate:types`) after upgrading. The footer column-row `link` group becomes conditional (hidden on text rows), so its columns become nullable in that migration; `rowType` defaults to `link`, so existing rows stay links. `@wabbit/tome-chrome` - **Navbar 7 `data-nav-scrolled`.** Navbar 7's existing `isScrolled` state (page scrolled past 20px) is exposed as `data-nav-scrolled="true"` / `"false"` on its `<nav>` root (`"false"` at rest and on the server render) and mirrored onto the header frame (the element carrying `data-visible` / `data-overlay` / `data-nav-theme`) once the navbar hydrates. Frames around the other navbars never carry it. Custom navbars can opt in with the new `useReportNavScrolled(isScrolled)` export. - **Footer `finePrint`** (array of `{ lead?, text }`, admin-visible for designVersion 7): small-type paragraphs Footer 7 renders below the link columns and above the bottom bar as `[data-footer-fine-print]`, each lead in `[data-footer-fine-print-lead]`. Tokens `--tome-footer-fine-print-size` (default `0.75rem`) and `--tome-footer-fine-print-measure` (default `72ch`). Exported as `FooterFinePrint` for other variants. - **Plain-text column rows.** `navItems[].subNavItems[]` gains `rowType` (`link` default | `text`), `text` and an optional `value`. A text row renders `<span data-footer-text-row>`; with a `value` it is a label/value pair, `<dl data-footer-text-row data-footer-pair><dt>…</dt><dd>…</dd></dl>` (Footer 7: label column at least `--tome-footer-pair-label-min`, default `5.5rem`). All eleven footers render both row types through the new shared `FooterColumnRow`; link rows render byte-identically. New types: `TomeFooterColumnRow`, `TomeFooterLinkRow`, `TomeFooterTextRow`, `TomeFooterFinePrintParagraph`. - **Navbar 7 theme tokens:** `--tome-header-pad-block` (header row block padding, default `2rem`, mobile bottom half of it), `--tome-header-curtain-radius` (default `1.5rem`) and `--tome-header-curtain-shadow` (default the previous shadow). Computed styles are unchanged when they are unset. The curtain radius moves from an inline style to the stylesheet, so Navbar 7's curtain `style` attribute no longer carries `border-bottom-*-radius`, and a non-default `desktopBreakpoint` override writes the padding through the same token. - **Navbar 7 curtain fill and blur tokens:** `--tome-header-curtain-bg` (the solid curtain's fill when the nav background is transparent, default the previous 80% page ground) and `--tome-header-curtain-blur` (its backdrop blur, default `12px`, also used by the token-background states). Computed styles are unchanged when they are unset. - **`aria-current="page"`** on any `NavLink` (every navbar and footer) that points at the current pathname (root-relative, trailing slash and query ignored, `#fragment` and external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's own `aria-current` wins. - **`header.menuLabel`** (text, localized, admin-visible for Navbar 7): optional visible text in the menu button beside the icon, `[data-menu-label]`. When set it is the button's accessible name; unset keeps the icon-only button and its "Toggle menu" name. `@wabbit/tome-forms` - **`appearance.stepsDisplay: 'wizard' | 'stacked'`** (default `wizard`, today's behaviour). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `defaultValue`s and under a restored draft, so the visitor's own input always wins. - **`TomeFormBlock`** (`@wabbit/tome-forms/blocks`): the `tomeForm` block renderer. It forwards `fieldDefaults` and only the block's `stepsDisplay` / `submitNote` when set, so a site gets the stacked intake just by setting the block options. The block's older appearance options (`layout`, `progressIndicator`, `themeOverride`) were never forwarded and still are not, so a block without the new options renders byte-identically. `TomeForm` itself gains `appearance` and `fieldDefaults` props for direct callers; without them it renders exactly as before. Also exported: `pickBlockAppearance`, `mergeAppearance`. - **Starting values are server-rendered.** A field's `defaultValue` and the placement's `fieldDefaults` are written into the initial HTML (`<option selected>`, `value`, `checked`), so there is no placeholder flash before hydration. Fields without a starting value render unchanged; forms whose fields declare a `defaultValue` now show it in the server HTML. - **A saved `localStorage` draft is now restored right after mount** (with `reset()`), instead of during the first client render. The first client render now matches the server HTML, where the draft read caused a hydration mismatch before. The draft still wins over starting values. - **BREAKING:** **`react-hook-form` peer floor raised from `>=7.0.0` to `>=7.60.0`** (devDependency `^7.60.0`). The draft restore calls `reset(values, { keepFieldsRef: true })`, and 7.60.0 is the first release whose `reset` honours `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
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 honours a `LogoComponent` override, and footers gain `--tome-footer-pad-block`, `--tome-footer-nav-cols` and a `data-footer-nav` hook. Navbar 7 used to render its stacked `logo`/`logoDark` images even when `HeaderRenderer` received a `LogoComponent`. The override now replaces them inside a `[data-navbar-logo="component"]` wrapper that inherits the bar's text colour, and receives `isDarkModeEnabled: true` whenever the bar wants its light-on-dark logo. Without an override, the `navTheme` image behaviour is unchanged. Footers 1–5, 7 and 8 read their block padding from `--tome-footer-pad-block` (default `8rem`). Footer 7's link grid carries `data-footer-nav` and reads its column count from `--tome-footer-nav-cols` (default `3`). The defaults match the previous fixed values, so nothing changes until a theme sets them.

  • e6037f6: Navbar 7 now honours a `LogoComponent` override, and footers gain `--tome-footer-pad-block`, `--tome-footer-nav-cols` and a `data-footer-nav` hook. Navbar 7 used to render its stacked `logo`/`logoDark` images even when `HeaderRenderer` received a `LogoComponent`. The override now replaces them inside a `[data-navbar-logo="component"]` wrapper that inherits the bar's text colour, and receives `isDarkModeEnabled: true` whenever the bar wants its light-on-dark logo. Without an override, the `navTheme` image behaviour is unchanged. Footers 1–5, 7 and 8 read their block padding from `--tome-footer-pad-block` (default `8rem`). Footer 7's link grid carries `data-footer-nav` and reads its column count from `--tome-footer-nav-cols` (default `3`). The defaults match the previous fixed values, so nothing changes until a theme sets them.
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, normalised to a `tel:` link), `ctaLabel` (localized) and `ctaHref`; each action renders only when its fields are complete. `HeaderRenderer` mounts the bar beside the navbar, with no new props. It shows only below the active navbar's desktop breakpoint (`64em` for navbars 1, 3, 4, 5 and custom variants, `48em` for 2 and 6, `desktopBreakpoint` for 7), slides in once the page has scrolled past about the first viewport and hides again near the top, respects safe-area insets, adds its height to the body's bottom padding while visible (also published as `--tome-mobile-action-bar-offset`), and is `inert` while hidden. Under reduced motion it appears without the slide; without JavaScript it is always visible on phones. Theme hooks: `[data-tome-mobile-action-bar]`, `[data-action="call"]`, `[data-action="cta"]` and `--tome-mobile-action-bar-*` custom properties. New exports from `./header`: `MobileActionBar`, `resolveMobileActionBar`, `resolveHeaderDesktopBreakpointPx`, `toTelHref`; new type `TomeMobileActionBarData`.

  • b2646c3: The Header global gains an optional `mobileActionBar` group: a phone-width bar fixed to the bottom of the screen with a Call (`tel:`) action and one primary CTA, available on every navbar variant. **Migration:** this adds fields to the Header global, so run your Payload migration (`payload migrate:create`, then `payload migrate` on SQL databases) and regenerate your Payload types after upgrading. Nothing renders until an editor turns it on: `mobileActionBar.enabled` defaults to `false`, and Header documents without the group render byte-identically. The group holds `enabled`, `callLabel` (localized; blank means "Call"), `phone` (written for people, normalised to a `tel:` link), `ctaLabel` (localized) and `ctaHref`; each action renders only when its fields are complete. `HeaderRenderer` mounts the bar beside the navbar, with no new props. It shows only below the active navbar's desktop breakpoint (`64em` for navbars 1, 3, 4, 5 and custom variants, `48em` for 2 and 6, `desktopBreakpoint` for 7), slides in once the page has scrolled past about the first viewport and hides again near the top, respects safe-area insets, adds its height to the body's bottom padding while visible (also published as `--tome-mobile-action-bar-offset`), and is `inert` while hidden. Under reduced motion it appears without the slide; without JavaScript it is always visible on phones. Theme hooks: `[data-tome-mobile-action-bar]`, `[data-action="call"]`, `[data-action="cta"]` and `--tome-mobile-action-bar-*` custom properties. New exports from `./header`: `MobileActionBar`, `resolveMobileActionBar`, `resolveHeaderDesktopBreakpointPx`, `toTelHref`; new type `TomeMobileActionBarData`.
  • fe163ec: The Header global gains a `navTheme` option ("Transparent Header Contrast") so Navbar7's transparent at-rest state can sit over a light hero, and every built-in footer now carries `data-footer-variant`. **Migration:** `navTheme` is a new Header global field, so run your Payload migration (`payload migrate:create`, then `payload migrate` on SQL databases) and regenerate your Payload types after upgrading. It defaults to `'auto'`, which keeps the existing look. `navTheme` is `'auto'` (default: light text and logo for a dark hero, as before), `'light'` (dark text and the main `logo` for a light hero) or `'dark'`. Navbar7 applies it to its transparent at-rest state; the scrolled and open solid state still follows the Background Color tokens. The logo swap uses the existing `logo` / `logoDark` pair, with the CSS-inverted `logo` as the fallback when no `logoDark` is set. Every variant writes the resolved value to `data-nav-theme` on the header frame for theme CSS. New type `TomeNavTheme`. Footers: each built-in footer writes `data-footer-variant="<key>"` on its root element, using the key `FooterRenderer` resolved (the fallback variant's key when `designVersion` is not registered), so themes can target a footer design without relying on the landmark element. `TomeFooterVariantProps` gains an optional `variantKey`, which `FooterRenderer` passes to custom variants so they can emit the same attribute.
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 licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
  • 77dc851: `HeaderActions`, `HeaderCTAButtons` and `HeaderSearchButton` no longer declare `'use client'`, so a server-rendered custom navbar can use them directly, including passing a `searchAdapter` function; the built-in navbars render exactly as before.
v0.9.2patch

0041ab0: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • 0041ab0: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
  • 7785db0: `NavGuard` now passes its capability to `@wabbit/tome-core`'s `CapabilityGate` as the `requires` list the gate actually accepts. It previously passed a singular `capability` prop, which left `requires` undefined and crashed any guarded nav item with `Cannot read properties of undefined (reading 'join')` in every install where `tome-core` was present; the fail-open path (gate package absent) was the only one that ever worked. The new test suite pins both paths through a test seam, so the contract can no longer drift silently.
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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • b01ca1f: Pin each layer's registered version to `package.json` instead of a hand-typed literal. `registerLayer(name, { version })` is the contract a consumer reads back through `hasLayer`/`getLayer` to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed **silently** — nothing throws when a version string is stale. | Package | Registered | Actual | | --------------------------- | ------------------------------- | ------ | | `@wabbit/tome-rpg` | `'0.1.2'` | 0.2.2 | | `@wabbit/tome-gamification` | `'0.1.0'` | 0.3.1 | | `@wabbit/tome-crm` | `'0.3.0'` | 0.5.0 | | `@wabbit/tome-ai` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_INTAKE_VERSION = '0.1.0'` | 0.3.1 | | `@wabbit/tome-marketing` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-chrome` | `'0.6.0'` | 0.8.5 | Each package now carries a leaf `src/version.ts` exporting `<NAME>_LAYER_VERSION`, read by its `registerLayer` call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local `TOME_*_VERSION` consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is. The forcing function ships with the fix. `pnpm assert:layer-version` (new, wired into `platform-discipline.yml` pre-build) parses every `registerLayer` call in the repo, resolves its `version` argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test. Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run. crm, forms, intake, marketing and rpg gained their first test suite in the process (`tests/layer-version.test.ts`) and were removed from the `assert:test-floor` starting-debt allowlist. No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
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 honour the same container tokens the navbars already expose: `--tome-chrome-content-max` and `--tome-chrome-content-inline`. Footers 1–9 and 11 previously hardcoded `max-width: 1200px` (1400px for footer9) and `padding-inline: 1rem`, so a consumer whose page grid is wider than 1200px could not align its footer without overriding CSS modules from outside the package — even though tome-ui's grid margin column and the navbar default share the identical `clamp(1rem, 4vw, 3rem)` formula. Each fallback preserves that variant's historical value, so a consumer setting neither token renders identically; this is opt-in alignment, not a visual change. footer10 has no `.container` rule and is untouched. Also: footer11's `.navCols` hardcoded three columns at `>= 48em`, orphaning any fourth nav group onto its own row — now `repeat(auto-fit, minmax(9rem, 1fr))`, which still resolves to three equal columns for three groups.

  • Footer variants now honour the same container tokens the navbars already expose: `--tome-chrome-content-max` and `--tome-chrome-content-inline`. Footers 1–9 and 11 previously hardcoded `max-width: 1200px` (1400px for footer9) and `padding-inline: 1rem`, so a consumer whose page grid is wider than 1200px could not align its footer without overriding CSS modules from outside the package — even though tome-ui's grid margin column and the navbar default share the identical `clamp(1rem, 4vw, 3rem)` formula. Each fallback preserves that variant's historical value, so a consumer setting neither token renders identically; this is opt-in alignment, not a visual change. footer10 has no `.container` rule and is untouched. Also: footer11's `.navCols` hardcoded three columns at `>= 48em`, orphaning any fourth nav group onto its own row — now `repeat(auto-fit, minmax(9rem, 1fr))`, which still resolves to three equal columns for three groups.
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.13.2
v0.13.2patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 behaviour 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 recognises `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 behaviour 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 recognises `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 behaviour 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 authorised 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 authorised 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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` -> cancelled, `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` -> cancelled, `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.6.1
v1.6.1patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.

  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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.1.4
v0.1.4patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 behaviour 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 behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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).

Crowdfund

v0.2.1
v0.2.1patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 behaviour 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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 behaviour 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.6.2
v0.6.2patch

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

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

579cf8d: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • 579cf8d: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour 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 favour 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 favour 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 behaviour (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 behaviour (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 authorises 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. **BEHAVIOUR 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 authorises 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. **BEHAVIOUR 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.2.1
v0.2.1patch

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

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

dde4ed0: **BREAKING:** `cancelSubscription` now rejects a period-end cancel instead of silently cancelling 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 cancelling 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.6.3
v0.6.3patch

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

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

07331e1: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • 07331e1: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • b01ca1f: Pin each layer's registered version to `package.json` instead of a hand-typed literal. `registerLayer(name, { version })` is the contract a consumer reads back through `hasLayer`/`getLayer` to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed **silently** — nothing throws when a version string is stale. | Package | Registered | Actual | | --------------------------- | ------------------------------- | ------ | | `@wabbit/tome-rpg` | `'0.1.2'` | 0.2.2 | | `@wabbit/tome-gamification` | `'0.1.0'` | 0.3.1 | | `@wabbit/tome-crm` | `'0.3.0'` | 0.5.0 | | `@wabbit/tome-ai` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_INTAKE_VERSION = '0.1.0'` | 0.3.1 | | `@wabbit/tome-marketing` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-chrome` | `'0.6.0'` | 0.8.5 | Each package now carries a leaf `src/version.ts` exporting `<NAME>_LAYER_VERSION`, read by its `registerLayer` call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local `TOME_*_VERSION` consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is. The forcing function ships with the fix. `pnpm assert:layer-version` (new, wired into `platform-discipline.yml` pre-build) parses every `registerLayer` call in the repo, resolves its `version` argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test. Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run. crm, forms, intake, marketing and rpg gained their first test suite in the process (`tests/layer-version.test.ts`) and were removed from the `assert:test-floor` starting-debt allowlist. No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
  • 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 behaviour changed in marketing or intake; forms and crm ship one behaviour 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 behaviour 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 behaviour: (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.7.2
v0.7.2patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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 behaviour 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 behaviour: (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.3.4
v0.3.4patch

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

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

3bba98a: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • 3bba98a: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour 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 honoured, 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 labelled and every unchanged row labelled. `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 favour 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 behaviour changed in marketing or intake; forms and crm ship one behaviour 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.1
v0.5.1patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 labelled as the only untested corner, since no consumer removes a tag yet. **Behaviour 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 labelled as the only untested corner, since no consumer removes a tag yet. **Behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • b01ca1f: Pin each layer's registered version to `package.json` instead of a hand-typed literal. `registerLayer(name, { version })` is the contract a consumer reads back through `hasLayer`/`getLayer` to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed **silently** — nothing throws when a version string is stale. | Package | Registered | Actual | | --------------------------- | ------------------------------- | ------ | | `@wabbit/tome-rpg` | `'0.1.2'` | 0.2.2 | | `@wabbit/tome-gamification` | `'0.1.0'` | 0.3.1 | | `@wabbit/tome-crm` | `'0.3.0'` | 0.5.0 | | `@wabbit/tome-ai` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_INTAKE_VERSION = '0.1.0'` | 0.3.1 | | `@wabbit/tome-marketing` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-chrome` | `'0.6.0'` | 0.8.5 | Each package now carries a leaf `src/version.ts` exporting `<NAME>_LAYER_VERSION`, read by its `registerLayer` call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local `TOME_*_VERSION` consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is. The forcing function ships with the fix. `pnpm assert:layer-version` (new, wired into `platform-discipline.yml` pre-build) parses every `registerLayer` call in the repo, resolves its `version` argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test. Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run. crm, forms, intake, marketing and rpg gained their first test suite in the process (`tests/layer-version.test.ts`) and were removed from the `assert:test-floor` starting-debt allowlist. No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
  • 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 behaviour changed in marketing or intake; forms and crm ship one behaviour 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 behaviour 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 behaviour: (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.6.1
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 behaviour). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `defaultValue`s and under a restored draft, so the visitor's own input always wins. - **`TomeFormBlock`** (`@wabbit/tome-forms/blocks`): the `tomeForm` block renderer. It forwards `fieldDefaults` and only the block's `stepsDisplay` / `submitNote` when set, so a site gets the stacked intake just by setting the block options. The block's older appearance options (`layout`, `progressIndicator`, `themeOverride`) were never forwarded and still are not, so a block without the new options renders byte-identically. `TomeForm` itself gains `appearance` and `fieldDefaults` props for direct callers; without them it renders exactly as before. Also exported: `pickBlockAppearance`, `mergeAppearance`. - **Starting values are server-rendered.** A field's `defaultValue` and the placement's `fieldDefaults` are written into the initial HTML (`<option selected>`, `value`, `checked`), so there is no placeholder flash before hydration. Fields without a starting value render unchanged; forms whose fields declare a `defaultValue` now show it in the server HTML. - **A saved `localStorage` draft is now restored right after mount** (with `reset()`), instead of during the first client render. The first client render now matches the server HTML, where the draft read caused a hydration mismatch before. The draft still wins over starting values. - **BREAKING:** **`react-hook-form` peer floor raised from `>=7.0.0` to `>=7.60.0`** (devDependency `^7.60.0`). The draft restore calls `reset(values, { keepFieldsRef: true })`, and 7.60.0 is the first release whose `reset` honours `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.

  • 0d925a4: Counsel v1 chrome and forms hooks. Every new field is optional, and existing output is unchanged when it is empty. **Migration: the new Payload fields add columns and tables.** Sites that register the Footer global, the `forms` collection or the `tomeForm` block must run their Payload migration (`payload migrate:create`, then `payload migrate`) and regenerate types (`payload generate:types`) after upgrading. The footer column-row `link` group becomes conditional (hidden on text rows), so its columns become nullable in that migration; `rowType` defaults to `link`, so existing rows stay links. `@wabbit/tome-chrome` - **Navbar 7 `data-nav-scrolled`.** Navbar 7's existing `isScrolled` state (page scrolled past 20px) is exposed as `data-nav-scrolled="true"` / `"false"` on its `<nav>` root (`"false"` at rest and on the server render) and mirrored onto the header frame (the element carrying `data-visible` / `data-overlay` / `data-nav-theme`) once the navbar hydrates. Frames around the other navbars never carry it. Custom navbars can opt in with the new `useReportNavScrolled(isScrolled)` export. - **Footer `finePrint`** (array of `{ lead?, text }`, admin-visible for designVersion 7): small-type paragraphs Footer 7 renders below the link columns and above the bottom bar as `[data-footer-fine-print]`, each lead in `[data-footer-fine-print-lead]`. Tokens `--tome-footer-fine-print-size` (default `0.75rem`) and `--tome-footer-fine-print-measure` (default `72ch`). Exported as `FooterFinePrint` for other variants. - **Plain-text column rows.** `navItems[].subNavItems[]` gains `rowType` (`link` default | `text`), `text` and an optional `value`. A text row renders `<span data-footer-text-row>`; with a `value` it is a label/value pair, `<dl data-footer-text-row data-footer-pair><dt>…</dt><dd>…</dd></dl>` (Footer 7: label column at least `--tome-footer-pair-label-min`, default `5.5rem`). All eleven footers render both row types through the new shared `FooterColumnRow`; link rows render byte-identically. New types: `TomeFooterColumnRow`, `TomeFooterLinkRow`, `TomeFooterTextRow`, `TomeFooterFinePrintParagraph`. - **Navbar 7 theme tokens:** `--tome-header-pad-block` (header row block padding, default `2rem`, mobile bottom half of it), `--tome-header-curtain-radius` (default `1.5rem`) and `--tome-header-curtain-shadow` (default the previous shadow). Computed styles are unchanged when they are unset. The curtain radius moves from an inline style to the stylesheet, so Navbar 7's curtain `style` attribute no longer carries `border-bottom-*-radius`, and a non-default `desktopBreakpoint` override writes the padding through the same token. - **Navbar 7 curtain fill and blur tokens:** `--tome-header-curtain-bg` (the solid curtain's fill when the nav background is transparent, default the previous 80% page ground) and `--tome-header-curtain-blur` (its backdrop blur, default `12px`, also used by the token-background states). Computed styles are unchanged when they are unset. - **`aria-current="page"`** on any `NavLink` (every navbar and footer) that points at the current pathname (root-relative, trailing slash and query ignored, `#fragment` and external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's own `aria-current` wins. - **`header.menuLabel`** (text, localized, admin-visible for Navbar 7): optional visible text in the menu button beside the icon, `[data-menu-label]`. When set it is the button's accessible name; unset keeps the icon-only button and its "Toggle menu" name. `@wabbit/tome-forms` - **`appearance.stepsDisplay: 'wizard' | 'stacked'`** (default `wizard`, today's behaviour). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `defaultValue`s and under a restored draft, so the visitor's own input always wins. - **`TomeFormBlock`** (`@wabbit/tome-forms/blocks`): the `tomeForm` block renderer. It forwards `fieldDefaults` and only the block's `stepsDisplay` / `submitNote` when set, so a site gets the stacked intake just by setting the block options. The block's older appearance options (`layout`, `progressIndicator`, `themeOverride`) were never forwarded and still are not, so a block without the new options renders byte-identically. `TomeForm` itself gains `appearance` and `fieldDefaults` props for direct callers; without them it renders exactly as before. Also exported: `pickBlockAppearance`, `mergeAppearance`. - **Starting values are server-rendered.** A field's `defaultValue` and the placement's `fieldDefaults` are written into the initial HTML (`<option selected>`, `value`, `checked`), so there is no placeholder flash before hydration. Fields without a starting value render unchanged; forms whose fields declare a `defaultValue` now show it in the server HTML. - **A saved `localStorage` draft is now restored right after mount** (with `reset()`), instead of during the first client render. The first client render now matches the server HTML, where the draft read caused a hydration mismatch before. The draft still wins over starting values. - **BREAKING:** **`react-hook-form` peer floor raised from `>=7.0.0` to `>=7.60.0`** (devDependency `^7.60.0`). The draft restore calls `reset(values, { keepFieldsRef: true })`, and 7.60.0 is the first release whose `reset` honours `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
v0.4.1patch

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

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

bb354ae: Forms now honour 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 honour 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 behaviour 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 behaviour 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 behaviour:** 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 honoured, 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 labelled and every unchanged row labelled. `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 behaviour changed in marketing or intake; forms and crm ship one behaviour 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

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

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

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
  • 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; behaviour 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; behaviour 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 behaviour 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 behaviour 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`. Behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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' behaviour too, it is the obvious next thing to tighten, and tightening it would change behaviour 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 behaviour 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 characterisation 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 characterisation 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 recognises 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 behaviour 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 recognises 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 behaviour 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.10.6
v0.10.6patch

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

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

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
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.4
v0.3.4patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 behaviour change.
  • bcdf9e5: The root barrel now loads under plain Node (Payload CLI), and the award utilities honour 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 honour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.

  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • b01ca1f: Pin each layer's registered version to `package.json` instead of a hand-typed literal. `registerLayer(name, { version })` is the contract a consumer reads back through `hasLayer`/`getLayer` to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed **silently** — nothing throws when a version string is stale. | Package | Registered | Actual | | --------------------------- | ------------------------------- | ------ | | `@wabbit/tome-rpg` | `'0.1.2'` | 0.2.2 | | `@wabbit/tome-gamification` | `'0.1.0'` | 0.3.1 | | `@wabbit/tome-crm` | `'0.3.0'` | 0.5.0 | | `@wabbit/tome-ai` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_INTAKE_VERSION = '0.1.0'` | 0.3.1 | | `@wabbit/tome-marketing` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-chrome` | `'0.6.0'` | 0.8.5 | Each package now carries a leaf `src/version.ts` exporting `<NAME>_LAYER_VERSION`, read by its `registerLayer` call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local `TOME_*_VERSION` consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is. The forcing function ships with the fix. `pnpm assert:layer-version` (new, wired into `platform-discipline.yml` pre-build) parses every `registerLayer` call in the repo, resolves its `version` argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test. Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run. crm, forms, intake, marketing and rpg gained their first test suite in the process (`tests/layer-version.test.ts`) and were removed from the `assert:test-floor` starting-debt allowlist. No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
  • 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
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 recognises 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 behaviour 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 recognises 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 behaviour 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.14.6
v0.14.6patch

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

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

  • d3ad4ce: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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 behaviour 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 characterisation 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 behaviour 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 capitalised 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 capitalised 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 behaviour 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 behaviour 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 behaviour 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) behaviour, 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 behaviour 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) behaviour, 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 behavioural 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 behavioural 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.1
v0.5.1patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 behaviour 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` behaviour 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.

  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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 behaviour 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 honour 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 behaviour change for tenant users: non-admins are scoped exactly as before.

  • 3087d61: Platform operators can see tenant accounts again — `accountScopedRead`, `accountMember` and `accountPermission` now honour 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 behaviour 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.2
v0.3.2patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 behaviour 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.

  • 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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 behaviour 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.2
v0.2.2patch

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

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
  • 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 honour 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 honour 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 behaviour 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 behaviour 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 behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 characterisation 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-normalising 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 BEHAVIOUR (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 behavioural 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 behaviour 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 honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
  • 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 behaviour 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 favour 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 characterised 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 characterised 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 behavioural 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 behavioural change — same functions, same leaf they always resolved to.
  • 19ba623: Retire the local `authorizedCronRequest` copy in favour 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. Behaviour 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 behaviour 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 behaviour. 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 behaviour. 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 behaviour 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 behaviour. 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 behaviour. 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.28.5
v0.28.5patch

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

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

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

  • 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
  • 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 labelled "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`. Behaviour 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`. Behaviour 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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
  • 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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` normalises null to `undefined` so React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup. **Behaviour 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.2
v0.2.2patch

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

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
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 favour 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 colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.

  • 48773ac: Fix a systemic, invisible-text readability defect across the block packs: a text colour 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 colours used as text.** `warning`/`success`/`error`/ `destructive` are FILL colours, 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 one declaration per band with no combinatorial selectors. longform additionally derives `--_accent-ink` by mixing the injected tome-cop accent half-and-half with `--tome-color-foreground`, which keeps the hue while binding legibility to a pair the house guarantees, and inverts by itself in dark mode. Blocks that deliberately paint NOTHING and sit in the prose flow keep the house PAGE pair. Migrating those to pack ink would be the same bug pointing the other way — a near-white ink on a light article. ## A fourth mechanism, found on the second pass: cross-namespace `:root` emission `@wabbit/tome-cop` drives the three packs' surface/ink sets, and it declared those aliases inside its `:root, [data-tome-pack="cop"]` rule. `:root` there is load-bearing for the `--cop-*` namespace and justified in that file on collision-safety grounds — no other package can declare a `--cop-*` property. `--dispatch-*`, `--readout-*` and `--signal-*` are other packages' namespaces, so the argument does not carry, and the consequence was that **importing** tome-cop's stylesheet — without ever setting `[data-tome-pack="cop"]`, which is the documented opt-in — re-themed three packs the site never opted into. Both declarations sit at `:root`, cop loads last, cop wins. Measured on the starter block gallery, a light bone/ink theme: `--dispatch-surface`, `--readout-surface` and `--signal-panel` all computed to `hsl(0 0% 100%)`, identical to the consumer's `--card`, while the packs' on-dark accents kept painting on top — 1.5–1.9:1 across dispatch, readout and signal-theme. The accents were correct as authored; the panel beneath them had been replaced. Every cross-namespace alias in that file — §5.2.3–§5.2.6's `--readout-*` / `--dispatch-*` status aliases, all of §5.2.8, and §5.2.8b's surface/ink sets — now lives in a `[data-tome-pack="cop"]`-only rule. Outside a cop-themed subtree each pack falls back to its own literals, which are contrast-checked against its own surface. This also removes a second failure the first one was masking: cop's zinc `oklch(45% 0.01 0)` for `--readout-objective-pending` and `--readout-personnel-inactive` reads 2.6:1 against readout's own dark panel, where the pack's own `hsl(0 0% 54%)` reads 5.4:1. Scoping rule going forward: a theme pack may emit its OWN namespace at `:root`; anything that re-themes a namespace it does not own goes behind the pack attribute. ## A fifth mechanism, found on the third pass: ink flipped, surface never painted Four hero-shaped blocks flip to light ink the moment a background image is declared — the copy is meant to sit on a photo under a dark scrim — but none of them painted a surface an ancestor of that copy could pair against. extras' **StudyHero** and **CustomHero** (its `cop`/`sitrep` families) painted no surface at all; marketing-starter's **HighImpactHero** painted its plate on the absolutely positioned background LAYER, a sibling of the content rather than an ancestor of it. So the real backdrop under the glyph was the page: measured 1.00–1.06:1, and the same failure reaches any consumer whose asset is absent, transparent, letterboxed, or simply slow to load. A hero added without an image rendered invisible copy. Each now paints the plate on the section itself, defaulting to the theme-relative partner of the ink it already chose — the shape BlogHero, ChapterHero and TypographyHero were already using. It is painted unconditionally rather than behind a `has-image` flag (org-pack's CampaignBanner `data-has-banner` shape) because both states want the same colour: with a photo it is the plate underneath; without one it is the dark band the ink was designed for, so the degraded state is a legible dark hero instead of a blank one. Each band publishes its ink as `--_on-band`, which matters most in HighImpactHero, where the muted tier is a DARK ink chosen for the page and would otherwise be dark-on-dark inside the new plate. marketing-starter's **Faq** had the mirror of this: `.bg-dark` set `color` on the section, but `.headline` / `.intro` / `.question` / `.answer` and the `+`/`−` marker each re-declared their own, and a child declaration beats an inherited one. `.question` was an exact foreground-on-foreground render at 1.00:1. signal-theme's **SignalDataTable** caption is the one piece of text in that block that is NOT inside the painted panel, and it kept `--signal-ink-muted`, a light grey tuned for `--signal-panel` — 2.17:1 on a light article. It now uses the house muted tier, the same rule SignalImageGrid's captions and SignalFootnotes already follow: panel-painted text uses `--signal-*`, prose-flow text uses the house vocabulary that tracks the ambient theme. ## Also fixed: a third icon-name-as-text renderer agency-essentials' **Timeline** rendered `section.icon` as children, painting the authored names (`rocket`, `briefcase`, `globe`, `zap`) as literal text — bone on bone, 1.00:1, on its dark variant — even though the block's own authoring guidance says "use icon names your renderer maps to an icon component". Same house pattern as catalog-pack's CategoryStrip and extras' own icon-bearing blocks: mapped names render an icon at `size="1em"` so the slot's font-size owns sizing, unmapped name-shaped strings render nothing, and an authored emoji still renders as text. Rather than add a third copy of the name→component map, `resolveLucideIcon` is now exported from `@wabbit/tome-blocks-extras/render/shared` — the barrel that already exists for helpers a consuming pack needs, and the package that already owns the `lucide-react` peer. Timeline's marker chip also hardcoded the page background as its fill while its glyph inherits the band ink, so on the dark variant it was a light chip carrying light ink. ## Also fixed, and not a contrast issue dispatch's CommsTranscript rendered redacted lines as the real message text with `color: transparent` under a painted bar. Invisible to sighted readers, still announced by screen readers and still present in the copied DOM — the redacted content leaked to exactly the readers a redaction exists for. The renderers now emit no message text at all for a redacted line. Every reference to a newly added token carries a literal fallback. An undefined custom property makes the declaration invalid and the element inherits its ancestor's colour, which is the 1.0:1 failure mode itself.
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.1.1
v0.1.1patch

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

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

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

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

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

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

Ai

v0.4.3
v0.4.3patch

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

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

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

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