# @stapel/categories-react

## 0.36.0

### Minor Changes

- e3a29cc: Contract pins move to stapel-listings 0.24.0 and stapel-categories 0.25.0, whose schemas pin every enum name: the drf-spectacular collision names (`StatusD41Enum`, `Type104Enum`, …) are gone from the generated `schema.ts` in favour of stable ones (`ListingStatusEnum`, `ConvertibleUnitTypeEnum`, …). `ListingLifecycleStatus` reads `ListingStatusEnum`. A host that referenced a collision name in the generated types renames it.

## 0.35.1

### Patch Changes

- 5992470: Docs teach code that lints and type-checks. listings-react README/MODULE.md: the composer's features wiring is `mapLoad(loadStateFromQuery(useCategoryFeatures(id)), (r) => r.features)` with `featuresError` spread only on failure (it was `features.data?.features ?? []`, which `stapel/no-flattened-load-state` rejects, and a MODULE.md snippet that did not type-check); MODULE.md states that `useCategoryFeatures` answers `CategoryFeaturesResult` (an object), not an array. categories-react README says the same. llms.txt (all three): query hooks carry their return type, and a new "Other hooks" section states each exported hook's call shape with its REQUIRED options (`useUploadQueue(...) — required { max; target }`).

## 0.35.0

### Minor Changes

- 22938ca: `partitionSides(children)`: the sides a partition parent files a listing under — its own row children, never a CategoryLink pointer into another branch and never a value of an expanded branch.

## 0.34.0

### Minor Changes

- An effective schema is a UNION, `data.json` is typed as the array it always
  answered, and a bulk id can be an integer.

  Regenerated against **stapel-categories 0.24.0**. The EFFECTIVE schema of a
  `chips` parent — `GET {id}/features/` under `X-Effective-From: children` — is
  now the UNION of its children's rather than their intersection, so a feature
  only SOME children carry is in the list instead of missing from it. Two
  additive keys say so and are exposed on `CategoryFeature`: `partial: true`,
  and `carried_by` naming the carrying children's slugs in `GET /children/`
  order (the bag's rows carry them as `partial` / `carriedBy`, normalized like
  `divergent`). `visibleFeatures` does NOT hide a partial row — its config is
  merged from the children that carry it and means one thing — so behaviour
  changes only in that more rows can arrive. Both keys are absent rather than
  false/empty on an `own` schema, so a leaf reads exactly as before.

  The same release re-emits the schema on core 0.84.0, and four more public
  shapes move with it. `GET /categories/data.json/` and `GET
/features/data.json/` declare `Category[]` / `Feature[]` where they declared a
  single object — the endpoint never changed, the statement about it did — and
  both lose the `revision` query parameter that was declared required and read by
  nothing, so a caller that omitted it was passing a type check it should have
  failed. `BulkUpdateResponse.updated_ids` is `(number | string)[]`: it is a pk
  list and this module's pks are integers, so `string[]` was wrong for every row
  it ever returned. `FeatureCreateUpdate.axis_role_derived` is a plain read-only
  `string` again rather than the enum ref, `FeatureEffective.divergent` is
  optional rather than required-and-readonly, and the three Feature write
  operations answer `FeatureCreateUpdate` instead of `Feature`. The operation ids
  of the two `data.json` reads change with their shape
  (`…_data.json_retrieve` -> `…_data.json_list`), which is visible in
  `manifest.json` and `llms.txt`.

## 0.33.1

### Patch Changes

- 8322284: Regenerated against **stapel-categories 0.23.1**: drf-spectacular's
  enum-collision resolver merged `AxisRoleEnum` and `AxisRoleAuthoredE96Enum`
  back into one `AxisRoleDerivedEnum` (identical five values — `make`,
  `model`, `generation`, `year`, `mileage` — no behaviour change on the wire's
  data). The declared backend contract moves to `>=0.23 <0.24` in
  `manifest.json` and `llms.txt`.

  Mechanical: no hand-written source in this package referenced either
  removed enum name by value, so the rename reaches only the generated
  `schema.ts`. One real type change rides along — `axis_role_derived` itself
  moves from bare `string` to the enum ref, a genuine tightening (a value
  outside the five now fails at the type level, where before any string
  passed). `docs/errors.json` and `docs/flows.json` are byte-identical.

## 0.33.0

### Minor Changes

- 1a1c240: `<CategoryMegaMenu minWidth>` defaults to the tokens' `desktop` rung, not a
  hard-coded 1024.

  The guard below which the panel renders nothing was `const DEFAULT_MIN_WIDTH =
1024` — a number on no rung of `@stapel/tokens` that happened to equal one
  deployment's private edge (its filter rail's "owner's tablet rule"). So the
  FLEET default silently carried one storefront's composition to every host that
  named nothing. A width that is a claim about every device belongs to the ladder
  or to the caller, never to a literal in between.

  The prop is unchanged and is still the way a deployment says otherwise — this
  moves only what a host gets when it says nothing. Same treatment as
  `<SearchPage railFrom>` (`@stapel/search-react` 0.32.7) and
  `<PublicShell chromeFrom>` (`@stapel/shell-react` 0.19.0); found by the sweep
  behind the latter.

  **Migration.** A host relying on the default now loses the panel between 1024
  and 1199, where it used to appear. If that band is wanted — and for a storefront
  whose catalogue button is already visible there, it is — pass the width
  explicitly, and pass the same one the deployment gives its other pairs:

  ```tsx
  // the deployment's own edge, named once and handed to every pair
  <CategoryMegaMenu minWidth={SERP_RAIL_MIN_WIDTH} />
  ```

  Below `breakpoints.desktop` with no `minWidth` the panel renders nothing AND
  asks the server nothing, exactly as before — the read is still skipped under
  the guard, now at the new width. Asserted at 767 / 768 / 1023 / 1024 / 1199 /
  1200 in `test/megaMenuGuard.test.tsx`, both for the default and for a host that
  names 1024.

  The component's demo now passes `minWidth={0}`: each variant declares
  `viewport: "desktop"`, but a declared frame is not the runner's
  `window.innerWidth`, and the photograph had been passing only because that
  width and the old default were both 1024. The guard is proven in the test; the
  demo photographs what the panel looks like.

## 0.32.0

### Minor Changes

- 9d05783: The mega-menu is an overlay in its own right: its own scroll box, the page held still, a wider rail, and a hover

  Owner's desktop read of the stand (2026-09-14): the wheel over the open «All
  categories» panel scrolled the PAGE, the root column was too narrow, and a
  root row gave no hover. Measured at 1440 headless before the change: the
  host's wrapper was the scroll box (`max-height: 796px; overflow-y: auto`),
  the panel inside it was 468px tall, so the box had nothing to scroll and the
  wheel chained to the document — 1500px of page in five turns, under a
  `position: fixed` panel that then re-anchored to a header that had left the
  screen. The rail was `minmax(180px, 1fr)` beside `3fr`: 338px of 1400.

  **`<CategoryMegaMenu>` is its own scroll container.** The panel carries
  `maxHeight` (default `MEGA_MENU_MAX_HEIGHT`, `calc(100dvh - 32px)`),
  `overflow-y: auto` and `overscroll-behavior: contain` — probed on Chromium
  153, `contain` stops the chain even on a box with nothing to scroll, so the
  wheel over the panel never moves the page whether or not the panel is tall
  enough to scroll. A host that stands the panel under a measured header passes
  the room actually left (`maxHeight={\`calc(100dvh - ${top}px - 16px)\`}`) and
  drops any scroll box of its own around it: two nested scroll containers give
  the wheel to whichever has the taller content, which is the outer one exactly
  when the panel fits.

  **The document is held still while the panel is mounted** on a wide viewport
  (`lockScroll`, default `true`): `overflow: hidden` on the root element with
  `scrollbar-gutter: stable` beside it so a classic scrollbar's gutter stays and
  the page does not widen under the panel; reference-counted, previous inline
  values restored exactly. A host drawing the panel inline as a page region
  passes `lockScroll={false}` — the showcase does. UPSTREAM ASK: this is the
  lock `SkinDialog` keeps module-private; `@stapel/tokens-antd/skin` should
  export it so the third copy is the last.

  **The rail is wider, and a token.** `minmax(MEGA_MENU_RAIL_WIDTH, 1fr)`
  beside `MEGA_MENU_PANE_FRACTION` fr — 360px at the floor, two sevenths of the
  panel above it (386px of 1400), and `railWidth` moves the floor. The
  reference's overlay could not be measured from this host (its edge refuses
  the address), so the number is a floor a host can move, not a measurement.
  Each root row ends in a chevron, the reference's row anatomy.

  **A root row has a hover and a focus fill.** The fill leaves the style
  attribute for a hoisted sheet (`megaMenuCss()`, deduped by
  `MEGA_MENU_STYLE_HREF`): `MEGA_MENU_ROOT_CLASS` on every root,
  `MEGA_MENU_ROOT_ACTIVE_CLASS` on the disclosed one, and `:hover`,
  `:focus-visible` and the modifier all paint `surface-sunken` — the design
  system's neutral tertiary fill, the same role the flat tile hovers with, and
  not the `brand-subtle` the disclosed row drew before: a pointer resting on a
  row is an answer about that row, and a brand tint there reads as a selection
  nobody made. The disclosed row still says which it is by weight. Pane links
  underline under the pointer.

  The `/default` size line moves 19.5 → 20 KB (19154 → 19530 B measured with
  dependencies held constant; the note in `package.json` carries the arithmetic).

## 0.31.4

### Patch Changes

- The card tile is tinted, not sunken

  `tileSurface="card"` drew `surface-sunken` — the neutral step BELOW the page.
  At #f4f5f7 against a #ffffff page that box is a hairline, so a grid of them read
  as a packed list with dividers rather than as the reference's illustrated cards:
  what a person saw was the gaps between the tiles and not the tiles.

  The fill is `brand-subtle` now — this vocabulary's own TINT role (brand.100 in
  light, brand.900 in dark). A colour rather than a shade of grey is the whole
  difference between "a row in a list" and "a card with a picture on it", and it
  is a declared ROLE and not a value picked in a leaf: a deployment that
  regenerates its palette moves this tile with it. `text` stays legible on both
  sides of it (gray.900 on brand.100, gray.100 on brand.900).

  The `size="compact"` card row takes the same tint, for the same reason it had a
  fill at all — the box is what makes two marks at opposite ends read as one
  thing.

  The FLAT tile's hover fill deliberately stays `surface-sunken`. A hover is a
  pointer's answer — "this is the one you are about to open" — drawn under the
  cursor and gone when it leaves; the page's accent colour there would read as a
  selection rather than as a hover, on whichever tile a mouse happens to rest.
  Nothing about the geometry moves: same radius, same ratio, same tracks, so every
  reservation and every measured stage is unchanged.

## 0.31.3

### Patch Changes

- e426b2d: A tile with no art of its own can inherit its parent's

  A walk of sixty category pages on a live classified found exactly one tile
  drawing a monogram: a services row surfacing on the transport page whose
  `catalog_icon` is empty, beside siblings that were all illustrated and under a
  parent that carried art. A monogram is the honest answer for a catalogue with
  no pictures at all; it is the wrong one for a single row inside an illustrated
  branch, and nothing walked up.

  `categoryTileEntry` takes an optional fallback icon and
  `categoryChildTileEntries` an optional parent to derive it from. A parameter
  rather than a lookup, because a tile entry is built from one row and that
  module has no tree to climb — a caller threading its own parent's resolved icon
  down gets a fallback that walks as far up as the caller does. A caller that
  passes nothing keeps exactly the old behaviour, and the fallback never
  overrides art a row actually has nor invents a reference where neither has one.

## 0.31.2

### Patch Changes

- 1292589: The compact row keeps its name, and the scroller's column count is a prop

  `size="compact"` is a horizontal row, `[caption][art]`, and the art took a flat
  32% of it. A storefront running that size at the scroller's default 2.5 columns
  on a 390px phone left ~90px of caption, and its two longest single-word root
  names ended in an ellipsis INSIDE the word. The name is the only part of a tile
  that says where the tap goes, so it is the part that may not be sacrificed.

  Two independent answers:

  `visibleColumns` (default 2.5, so no existing host moves) lets a deployment say
  how many columns of the scroller are in view. The right number depends on the
  longest NAME in the catalogue, which is a fact about the deployment rather than
  about the pair.

  And the compact row now gives its picture up to its name once it is narrow:
  the caption is `flex: 1 1 auto` with `min-width: 0` — so the art is what
  yields rather than what is subtracted first — and below 200px of TILE width the
  art drops to a 24px corner glyph. A container query and not a media query,
  because the same mount is a wide sidebar in one host and a phone strip in
  another, and only the tile knows which it ended up as.

## 0.31.1

### Patch Changes

- df257aa: `<CategoryTileGrid>`: a tile caption breaks between WORDS, never inside one.

  The compact scroller's column was a pure fraction of the port, which on a 390px
  phone is 63px — narrower than the catalogue's longest root names, so
  `overflow-wrap: anywhere` split them mid-word with nothing marking the break
  and the reader had to reassemble the word before deciding whether to tap it.

  The column was the defect, so the column is what changed: `clamp(96px,
calc(100% / 4.4 - gap), 128px)` puts a FLOOR under the fraction — the measure a
  twelve-letter root name needs at the caption's type size — and the compact tile
  spends one step less inline padding, which gives the caption 88px. Below the
  floor the grid gives up a column rather than a word; above it the fraction and
  the 128px cap are untouched, so every wider container is exactly what it was.

  With the room to be right, the caption is `overflow-wrap: normal` with
  `text-overflow: ellipsis` as the floor under a name no column can hold, and its
  type size is read from the design system's scale (`xs`) rather than written as
  a number.

## 0.31.0

### Minor Changes

- 0992fab: A breadcrumb trail is not a panel.

  `<CategoryBreadcrumbsBar>` wraps itself in `<SkinTheme>` to hand antd the theme
  its `Breadcrumb` is drawn with. `<SkinTheme>` defaults to `surface="raised"`,
  which also paints `colorBgContainer` on its own root — and on a light theme
  that is the page's own colour, so for as long as this skin has existed the
  fill was invisible and nobody ever decided it.

  Measured on a dark storefront by its integrator (a browser probe over the
  live site): a 1392×24 lighter ribbon across the top of every `/c/:slug`,
  and the same band at 1392×24 / 382×48 on every listing page that mounts the
  bar for its own trail. A panel behind one line of links, with no radius, no
  border, and nothing inside it that belongs on a surface of its own. It is the
  same fill the flat tile grid lost one release ago, one component over.

  The bar now renders `surface="bare"`: no background at all, so the trail takes
  the ground it stands on. `bare` drops the text colour `raised` would have
  written too, so the colour is stated back as `color: var(--stapel-text)` —
  the same trade `<CategoryTileGrid>`'s flat arm makes, and strictly better than
  what it replaces, because the custom property resolves per theme at paint time
  where `SkinTheme` froze the value of whichever side mounted first.

  No new prop. A host that pins `mode` still gets that mode: the pin is what
  gives the antd components below their tokens, and every caption in this bar —
  `Breadcrumb`, `Typography`, `CategoryLink`, the skeleton and the error alert —
  takes its colour from those tokens rather than by inheritance.

  `test/breadcrumbSurface.test.tsx` asserts the surface stamp and both halves of
  the paint, on the pinned-mode path as well as the default one. jsdom resolves
  no antd token, so the claim is the stamp `<SkinTheme>` publishes for exactly
  this purpose; it read `"raised"` before this change.

## 0.30.0

### Minor Changes

- 96cdb9b: A flat tile grid has no panel behind it.

  The tiles went flat in 0.27.0 and the box around them did not. `<SkinTheme>`
  defaults to `surface="raised"`, which paints `colorBgContainer` over the whole
  component — so the stand still showed a lighter strip the width of the grid on
  the desktop catalogue page, and a full panel on the phone and behind the
  landing's compact strip (owner's read of 0.29.0, dark). It is the same block
  fill the tiles themselves had just lost, one box further out, and no rule in
  this component could see it: the wrapper paints it, and everything the grid
  draws — the wrap grid, the phone scroller, the reserve box — sits inside that
  wrapper and paints nothing of its own. One gate is therefore all three, and
  each of the three is tested rather than assumed.

  With `tileSurface="flat"` (the default) the container is flat too: no
  background, no border, no shadow. The tiles sit on the page.

  Two details worth knowing:

  - `surface="bare"` paints _nothing_, including the text colour `"raised"` would
    have written, so the flat arm states `color: var(--stapel-text)` instead —
    which is strictly better than what it replaces, because the custom property
    resolves per theme at paint time where `SkinTheme` froze the value of
    whichever side mounted first.
  - the tiles' own hover fill (`surface-sunken`) is now the only fill in the box,
    which is what makes it read as a hover at all: over a `colorBgContainer`
    panel the two are one step apart and the highlight was nearly invisible in
    the dark theme.

  `tileSurface="card"` keeps the panel — a grid of filled tiles was designed on
  it. No new prop on the grid: the container follows the surface the tiles
  already take.

  **`<CategoryCarouselStrip>` takes the same rule**, and it gains the prop —
  `tileSurface?: "flat" | "card"`, default `"flat"`, the same word and the same
  default the grid uses, because a landing that draws both rows must not be flat
  in one and panelled in the next. It had BOTH fills: the `raised` wrapper behind
  the row and an antd `Card` behind every entry inside it. Flat, the wrapper is
  `bare` with the same token text colour, and there is no card at all — the LINK
  carries the tile's box (the padding and radius the card was giving it) and the
  two classes, so the whole tile is the pointer target and its hover fill comes
  from `<CategoryTileGrid>`'s own published rule set, hoisted under the same
  `href`. One sheet, one hover, no second definition to drift. `"card"` restores
  the strip exactly as it was.

  The `default` size budget goes 19 → 19.5 KB (18846 → 18967 B, dependencies held
  constant), written into the entry's own note. The change fits under the old
  line and would have left it with 33 B, which by that entry's own standard is
  not a ceiling but a gate that fails on the next honest byte — raised here
  rather than left as a red for the wave after this one.

## 0.29.0

### Minor Changes

- f71cdcc: A compact tile without a fill is a list row.

  Architect's verdict, superseding the owner's 2026-09-04 ruling **for the flat
  surface only**.

  `size="compact"` is the reference's second-level tile: a horizontal row half
  the root tile's height. Its 2026-09-04 anatomy puts the name against one end of
  that row and the picture against the other — which is a CARD's arrangement, for
  exactly the reason the regular tile's opposite corners were one. The filled box
  is what makes two marks at opposite ends read as one thing. With the fill gone
  (the owner's ruling for every tile) the same row is two pieces of scattered
  text with a gap in the middle, which is what the stand measured.

  So `size="compact"` + `tileSurface="flat"` is now a list row: **the art first,
  the caption second, adjacent, both against the leading edge**, with the design
  system's own gap between them and the caption still reading from that edge. It
  is the same fix the regular flat tile received, applied along the axis this
  anatomy actually runs in — proximity replacing the box.

  Height, density and padding are the 2026-09-04 ruling either way; only the
  arrangement inside the row moves. `size="compact"` + `tileSurface="card"` keeps
  that ruling exactly, opposite ends and all, and `size="regular"` + `flat` is
  untouched — it stays the centred stack.

  No new prop: this is the flat surface drawing correctly at both sizes. The
  `default` size budget holds at 19 KB (18797 → 18846 B, dependencies held
  constant), recorded in the entry's own note — 154 B of room left, and the next
  change to that entry spends them.

## 0.28.0

### Minor Changes

- 97c14d9: The flat tile is a stack, and the tile stage reserves the rows it draws.

  Both from the owner's read of 0.27.0 on the stand, in both themes.

  **The flat tile's own anatomy.** Taking the fill away was half the change. The
  card anatomy puts the caption in the top-left corner and the art in the
  bottom-right, and the filled box was what held those two marks together: with
  no fill they are two unrelated things with a void between them, and six of them
  across a desktop row read as a loose list rather than a grid of tiles.
  `tileSurface="flat"` now draws the arrangement the phone landing already reads
  correctly without a fill — the icon centred with the caption centred under it,
  so proximity does what the box used to. The caption keeps the card's own type
  size (not the ~80px dense tile's 12px) and its clamp still answers
  `labelLines`. The RATIO, the padding and the hover fill are unchanged, so a
  flat tile occupies exactly the box a card tile did and no reservation, track
  height or measured stage moves. `tileSurface="card"` keeps the corners.
  `size="compact"` keeps its own horizontal row (name left, small picture right,
  already adjacent) as ruled on 2026-09-04.

  **`tileStageRows(count, columns)` and `<CategoryTileGrid reserveCount>`.** Six
  tiles in five columns is two rows; five in five is one. The reservation drew a
  fixed four skeletons whatever was coming, and the scroller declared its two
  rows even with one tile in them — so the stage stood taller than its content
  and left an empty band under the last row before the next block (~100px on the
  storefront's `/c/transport` desktop). The reservation is now one skeleton per
  tile, in the same grid the tiles land in, so the browser lays out exactly
  `tileStageRows(count, columns)` rows for the reserve as it will for the answer
  — no second copy of the geometry to drift. The scroller declares
  `min(2, count)` rows. `reserveCount` defaults to 4, this arm's own long-standing
  number, so a caller that says nothing is unchanged; the rule itself is exported
  because a host holding the stage's box from outside this pair had nothing to
  ask and was guessing a pixel height.

  The `default` size budget holds at 19 KB (18652 → 18797 B, dependencies held
  constant), recorded in the entry's own note.

## 0.27.0

### Minor Changes

- 552cca2: A tile is an icon and a name, and one token spaces every block on a catalogue
  page.

  **`<CategoryTileGrid tileSurface>`** — default `"flat"`. `surface-sunken` behind
  every tile was inherited from the first carousel strip and never re-decided: a
  dozen of them on a landing read as a wall of filled boxes, each one competing
  with the picture it contains, and the fills carry no information. `"flat"` draws
  no fill and no border at rest and takes the surface back on `:hover` and on
  `:focus-visible` — the same token fill and the same radius the card tile wears,
  so the hovered tile is the tile the grid was already laid out for, and pointing
  at a tile is what colours it in. The keyboard focus ring is untouched. Both
  themes, through `--stapel-*` custom properties rather than a computed colour that
  would freeze whichever theme mounted first. `tileSurface="card"` restores today's
  filled tile exactly, and `<CategoryPage subcategoryTileSurface>` reaches the
  `"tiles"` arm with it. `CATEGORY_TILE_FLAT_CLASS` and `categoryTileCss()` are
  exported for a host drawing its own tiles.

  `<CategoryLink>` now forwards `className`, which core's `LinkComponentProps`
  already carried — a host router's own `<Link>` receives it unchanged.

  **`<CategoryPage blockRhythm>` / `<CatalogPage blockRhythm>`** — default
  `"token"`. The distance between two blocks was `spacing[4]` written inline on
  whichever `<Flex>` wrapped them: 16px is a form's field spacing, not a page's
  section spacing. Every gap now comes from ONE pair of custom properties,
  declared as a usage and not as a definition — `var(--stapel-block-gap, 32px)`,
  and `var(--stapel-block-gap-compact, 24px)` on a coarse pointer or under the
  tablet edge — with each direct block's outer margin reset in the same rule set.
  `@stapel/search-react` declares the SAME two property names, so a storefront that
  assembles a category screen out of both pairs tunes it with one declaration.
  `blockRhythm="legacy"` restores the flat inline gap.

  Both defaults are the NEW behaviour on purpose: neither the tile's fill nor the
  16px gap was a design anybody chose for these surfaces.

  The `default` size budget goes 18.5 → 19 KB, measured and written down in the
  entry's own note (18209 → 18652 B with dependencies held constant), rather than
  left as a red for somebody to rerun.

## 0.26.0

### Minor Changes

- 4e0f7b8: A child is not always a row: pointers and values (stapel-categories 0.22.0).

  The pin moves to v0.22.0 and `GET /{id}/children/` stops answering an array of
  `Category`. It answers `CategoryChild`, a union of three: a real subcategory; a
  POINTER into another branch (`linked: true`, every other key the TARGET's, so
  following it lands on the target's own page) inserted at the `order` its
  operator gave it AMONG the real children; and, on a category that expands a
  feature into its children (`children_expand_by`), a VALUE with no row at all —
  `name`, `value` and a `{feature slug: value}` `filter`.

  A pointer costs the pair no rendering path, and that is the contract's own
  promise kept rather than a shortcut: `CategoryRowChild` is `Category` plus
  `linked`, so every reader, tile and link already handles one. What the flag
  buys is the two refusals — `catalog/wrapper.ts` will not read a pointer as a
  one-rung import wrapper and will not collapse it for a target that happens to
  be `children_as: "transparent"`, either of which would put the inside of a
  branch where an operator drew its door. `hasChildren` answers `false` for a
  value before consulting anything else, so a lone value is not mistaken for a
  wrapper either. `browseChildren` therefore passes both new kinds through
  untouched and in place, and a level with neither comes back as the very same
  array it was handed.

  A value's address is the host's, and the pair will not guess one — the same
  rule `categoryIconSrc` keeps about a CDN base.
  `categoryChildTileEntries(children, basePath, hrefForVirtual?)` maps a whole
  level in the server's order: rows and pointers become the usual
  `CarouselEntry`, values become a `VirtualTileEntry` that `<CategoryTileGrid>`
  draws with the value's own caption and a monogram, linking wherever
  `hrefForVirtual(filter, child)` says. Without the callback a value is dropped
  and a development build says so. `<CategoryPage hrefForVirtual>` threads it
  into the page's `"tiles"` arm.

  Also on the wire and now on the types: `children_axis_tag` beside
  `children_axis_label` — the source catalogue's own identifier for the field a
  level enumerates, never shown, where the label is the translation key a person
  reads — and `children_expand_by`. The staff paths `/{id}/links/` are generated
  and deliberately not on `CategoriesApi`: a storefront renders the assembled
  child list, it never authors a pointer.

  The skin's size budget goes 17.8 -> 18.5 KB. 143 B of that was already owed
  before this change and 266 B is a value getting drawn; both are written into
  the budget entry's own note.

- 3bcacb7: The phone landing's tiles: a caption break a reader can see, and a pictogram on the "All" tile.

  `labelHyphens` is a new prop on `<CategoryTileGrid>` (default `"manual"` — no
  existing host changes). A 390px walk measured the compact tile's caption column
  at ~63px and a twelve-letter root name at ~83px in it: some break is forced, and
  the skin printed no hyphen at it, so a one-word name arrived as two unrelated
  fragments. D90 measured the opposite catalogue and its ruling stays the default;
  `"auto"` is how a deployment that measured this one asks for the mark, and the
  caption is stamped with the i18n engine's locale when it does — `hyphens: auto`
  without a language is inert, which is the half that fails silently.
  `overflow-wrap: anywhere` is unchanged under both, so a name with no hyphenation
  point in it still breaks rather than clipping.

  The grid's own "All" tile draws a four-cell pictogram instead of
  `<TileMonogram>`. It stands first among ten siblings that all draw a picture, so
  a lone faint capital there read as an image that had failed to load. It is the
  grid's own control and has no `catalog_icon` to wait for; the monogram is
  untouched as what a CATALOGUE ROW falls back to when its art has not been
  uploaded.

## 0.25.1

### Patch Changes

- 68d5906: The Spanish refusals quote the slug, like the Russian ones already did.

  Contract pin to stapel-categories `v0.21.7`, which fixed two es texts that
  dropped `{slug}` into prose bare — a multi-word slug read as the sentence
  continuing and losing its ending:

  - `error.400.categories_duplicate_slug`: `Ya existe una característica con el slug {slug}` → `…con el slug «{slug}»`
  - `error.404.categories_slug_not_found`: `No existe ninguna categoría con el slug {slug}` → `…con el slug «{slug}»`

  `docs/errors.json`, `docs/flows.json` and `docs/schema.json` are byte-identical
  across the span, so only the es locale bundle regenerates; nothing else in this
  package moves.

## 0.25.0

### Minor Changes

- 70ca317: The nine refusals this pair used to write are the module's own words now

  `stapel-categories` 0.21.5 ships `translations/errors.ru.json` and
  `errors.es.json` — the first release in which the nine error codes it owns have
  any language but English. Until today the pair stood in for that: `gen:errors`
  pointed `ERRORS_CATALOG_DIR` at **stapel-core's** catalogue, listed
  `stapel_categories` in `ERRORS_LOCALE_EXEMPT_OWNERS`, and `src/i18n/ru.ts` /
  `es.ts` carried nine hand-written sentences beside the generated bundle that did
  not.

  The pin moves to v0.21.5, the exemption drops, and those nine hand-written lines
  are **deleted** rather than kept beside the upstream ones. One string, one source:
  with both present the same key resolves twice and the two drift the next time
  either side is reworded. `categoriesErrorBundleRu` / `categoriesErrorBundleEs`
  (the `./i18n/ru` and `./i18n/es` exports) therefore grow by those nine keys, and
  stop being `Partial` for them.

  All nine wordings move, and every difference is punctuation or a synonym: upstream
  writes no trailing full stop, says "a request TO the feature editor" where the pair
  said "a feature editor request", and in `es` leaves `{slug}` unquoted where the pair
  wrote «{slug}». Nothing here was worth an override, so this pair now keeps none —
  `test/i18n.test.ts` reads `src/i18n/{ru,es}.ts` as text and fails if an `error.*`
  line comes back. **Upstream ask for stapel-categories**: quote the slug in the `es`
  texts for `categories_duplicate_slug` and `categories_slug_not_found`, so `es` reads
  like `ru` and like the module's other messages.

  The 13 `stapel_attributes` codes stay exempt and stay out — that catalogue's
  consumer is `@stapel/attributes-react`, which owns those keys.

  Also in the 0.21.1 → 0.21.5 span: `GET /categories/by-slug/{slug}/` documents a
  `301` to the current slug for a slug a category used to carry, which the generated
  client picks up; a category `slug` widens from 100 to 255 characters, which
  openapi-typescript does not emit. `docs/errors.json` and `docs/flows.json` are
  byte-identical across it.

## 0.24.2

### Patch Changes

- b7dd445: categories: the catalogue says which feature of a category is the make

  stapel-categories 0.21.0 resolves an axis role per feature — an authored value
  first, then `load_catalog`'s slug-table derivation — and serves it on every
  public feature read as `axis_role`, with `axis_role_authored` /
  `axis_role_derived` on the editor serializer. `docs/schema.json` moves, so
  `src/api/generated/schema.ts` regenerates against the new pin.

  **What a host can now read.** `CategoryFeatureEntry` gains `axisRole`
  (`"make" | "model" | "generation" | "year" | "mileage" | null`) and
  `CategoryFeaturesBag` gains `axes` — `{role: feature}` over the category's
  schema, which is the lookup a storefront does instead of matching slugs
  against a table of its own. The raw rows `useCategoryFeatures` and
  `bag.features` hand on carry the key untouched, and `AxisRole` is re-exported
  so a host names the type without depending on `@stapel/attributes-react`
  directly.

  **No UI.** `<CategoryFeatureList>` draws no axis badge; the skin's byte cost
  is the two extra fields travelling through the bag.

  **The vocabulary stays where it is owned.** Both readers are
  `@stapel/attributes-react`'s `axisRoleOf` / `byAxisRole`, so a role this build
  does not recognise reads as `null` rather than reaching a renderer as
  something nothing can act on, and a role TWO features claim is dropped rather
  than resolved — a «more of this make» link built off the wrong one sends a
  buyer to a facet they did not click. The features themselves still arrive,
  drawn and validated and faceted exactly as before: an unusable ROLE is not a
  dropped feature.

  The defect this ends is silent. A storefront's own table (`brand` / `make` /
  `vendor` / `manufacturer`) is always one catalogue behind, and a catalogue
  spelling the axis a fifth way produced no link and no error.

  Contract pins: stapel-categories v0.21.0 → v0.21.1, stapel-attributes v0.9.1 →
  v0.9.2 (0.21.1 raises the module's own floor to 0.9.2 for this field, which is
  why the two move together). Neither announced range moves — still
  `>=0.21 <0.22` and `>=0.9 <0.10`.

  Peer floor: `@stapel/attributes-react` `>=0.1.0` → `>=0.16.6`. The three
  symbols this pair now imports (`AxisRole`, `axisRoleOf`, `byAxisRole`) ship in
  that release, and the monorepo cannot catch a stale floor by building — in
  here every package compiles against the workspace peer, never against its own
  floor.

  Size: `dist/index.js` 10.45 → 10.75 KB, measured 10445 → 10582 B with
  dependencies held constant; `dist/default/index.js` 17.8 KB holds at 17725 B.

## 0.24.1

### Patch Changes

- 9c9316d: categories: the breadcrumb trail belongs to the frame, not to the address bar

  **One row still blanked on a partition press.** `<CategoryPage keepPrevious>`
  holds the whole frame across a sibling change — the heading, the sub-category
  chrome and whatever the host rendered into `renderListings` all stand while the
  new category's two reads are in flight. `<CategoryBreadcrumbsBar>` was handed
  the RAW `categoryId` anyway, so it started the new category's own reads
  immediately, went `loading`, and drew a lone `ant-skeleton-input-sm` where root
  → current had been. A held frame with a hole punched in the one row above it is
  the rebuild `keepPrevious` removed, just narrower.

  - `<CategoryPage>` hands the bar the row the FRAME holds
    (`source.state.data.current.id`), falling back to the address when there is
    nothing held — a first mount, or `keepPrevious={false}`. The trail now names
    the category the rest of the page is drawing, and moves when the frame moves.
  - `<CategoryBreadcrumbsBar keepPrevious>` and `<CategoryBreadcrumbs
keepPrevious>` — both default `false`, so a bar mounted alone in a header is
    byte-for-byte unchanged. `<CategoryPage>` passes its own. The kept trail
    refreshes IN PLACE and carries `data-stapel-load-refreshing="true"`
    (`<LoadBoundary>`) until the newer rows land, which is what covers the case
    the held row cannot: a jump to a category whose ancestors are not already in
    the cache.
  - A first load is still the skeleton (there is nothing behind it to keep), and
    a REFUSAL is still never held — `useKeptLoad`'s rules, not new ones.

  `test/keepPrevious.test.tsx` proves it structurally: across a sibling press the
  trail's ELEMENT IDENTITY holds, so no skeleton can have rendered at any point
  without the assertion catching it, and a `MutationObserver` counts the
  `categories-breadcrumbs-loading` nodes on every mutation batch rather than at
  the two moments a synchronous assertion happens to look.

  A storefront holding a partition press can go back to the blanket "no loading
  boundary" assertion.

  Measured, dependencies held constant: `dist/index.js` 10437 → 10445 B (limit
  10.45 KB, held — 5 B of room left), `dist/default/index.js` 17614 → 17713 B
  (limit 17.8 KB, held).

## 0.24.0

### Minor Changes

- 27308a1: **`<CategoryPage>` holds the page it is showing until the next one is whole.**
  A partition press navigates to a SIBLING: same page, same trail, same rail,
  same segmented control, different rows. It changed `categoryId`, sent
  `GET {id}/` and `GET {id}/children/` pending, and the page's `LoadBoundary`
  swapped its subtree for a four-row skeleton — which unmounted everything the
  host had rendered into `renderListings`, including the control the person had
  just pressed, its focus and its scroll position, twice, out and back (D454). A
  storefront worked around it by mounting these very hooks in its own container
  and withholding the id from this page until both had landed: 47 lines of
  container code to tell the page something the page already knew, and a second
  copy of the pair's own gating rule to keep in step with it.

  `useCategoryPageSource` now derives its state through core's
  `useKeptLoad({ keepPrevious })`, and the landing travels INSIDE the load state
  rather than beside it — the row, the level under it and its depth as one frame.
  That is what makes the hold mean anything: the heading, the sub-category chrome
  and the listings slot are read off ONE frame, so they can never disagree about
  which category is on screen. The `renderListings` node keeps its identity
  across a sibling change (asserted with a mount counter, not by looking for a
  skeleton — a test that only looked would pass against a remount that happened
  to be fast), and the page never draws the skeleton once it has drawn anything.

  While the next frame is in flight the ready arm carries
  `data-stapel-load-refreshing="true"` (`<LoadBoundary>`), so a host can dim or
  announce without owning the state. A category the page has never shown still
  loads with the skeleton — there is nothing behind it. A REFUSAL is never held:
  the error arm and its retry replace the page, because a dead category wearing
  the last live one is a dead link that looks alive. `keepPrevious={false}`
  restores the previous behaviour exactly, for a host that draws its own held
  frame and would otherwise hold one on top of another.

  **And a tile's caption has a name.** `data-testid="categories-tile-label"` is
  on the clamped `<span>` of every ordinary tile, in all three anatomies, and
  `CATEGORY_TILE_LABEL_TESTID` is exported so a host does not retype the string.

  The caption is FIRST inside the tile link on the regular and `size="compact"`
  tiles and SECOND on `density="compact"`, whose art leads — so everything
  reaching for it by position aims at the label on one surface and at the art on
  the next, and reports a number either way. That is not hypothetical twice over:
  it is the `span:first-child` `!important` clamp `labelLines` retired, and then
  a stand probe that read `-webkit-line-clamp` off the tile LINK (`none`, on a
  page where the clamp works perfectly) and counted the art box and the monogram
  among the caption's three "lines". A name that is on the clamped span in every
  anatomy is the difference between measuring the cap and measuring whatever was
  first.

  The two special tiles keep the names they already had on that same span —
  `categories-tile-grid-all`, `categories-tile-grid-more` — because a test
  reading the clamp off those already points at the right element, and renaming
  it would move nothing and break that.

## 0.23.0

### Minor Changes

- ba42588: **A tile label's line count is a number, not an `!important`.**
  `<CategoryTileGrid labelLines>` sets how many lines a tile's label may take
  before it is clipped, in whichever of the three anatomies is drawn — the "All"
  tile and the `overflow="modal"` tile included. The default is unchanged and
  stays per-anatomy (three lines on the regular tile, two on `density="compact"`
  and on `size="compact"`, where a small square and a half-height row have no
  third line to spend), so no existing host changes shape.

  The clamp is the only part of a label that depends on the CATALOGUE's words
  rather than on the tile, and it was the one part a host could not reach: it is
  an inline style, so a stylesheet needs `!important` to touch it, and the rule
  then has to name the label by POSITION. A deployed one named
  `span:first-child`, which is the label on the regular tile and the ART on
  `density="compact"` — so the override had been clamping nothing on the surface
  it was written for, while reading as coverage. `labelLines` moves the clamp and
  only the clamp: the type size, the alignment, and the `hyphens: manual` /
  `overflow-wrap: anywhere` pair that keep a long caption readable stay the
  skin's.

  `<CategoryPage subcategoryLabelLines>` reaches the same number from the
  `"tiles"` arm, alongside the `subcategoryTileSize` it travels with: the compact
  anatomy clamps at two because a half-height row has no third line, and a
  catalogue whose section names are longer needs that line without giving up the
  anatomy.

  **And `<CategoryPage measure>` says `"none"` out loud.** The prop already took
  anything CSS `max-width` takes; what it did not say is that removing the cap is
  a real answer rather than a very large number. A page mounted inside a shell
  whose content column already holds the measure every page of the site shares
  gets a SECOND, lower cap from this one — and the lower one wins, so the host's
  column silently stops applying to exactly one page. `measure="none"` is that
  page saying the measure is not its own, the same sentence `gutter={false}`
  makes about the indent. Documented on the prop, in the README, and covered by
  its own test; no API change.

## 0.22.3

### Patch Changes

- The reserved tile row is the SHAPE of the row that arrives.

  `<CategoryTileGrid>`'s loading arm — and, since it was shared, the `reserve` box a host-owned fetch waits in — drew every skeleton at `4 / 3`, the cozy tile's ratio, while a `density="compact"` tile is a square and a `size="compact"` tile is `8 / 3`. So the row was one height while it waited and a different one when it landed: 0.024 of layout shift on the storefront's home, paid on every cold load, by the arm that exists to prevent exactly that. The shape is now read off `tileStyle()` — the one place that already knows which anatomy is being drawn — rather than restated beside it, because a second copy of that decision is a copy that drifts.

  Ceiling raised deliberately: the `default` bundle 17.43 → 17.56 KB across this wave, measured with dependencies held constant, 17.6 → 17.8 KB (40 B of room left is a gate that fails on the next honest byte).

## 0.22.2

### Patch Changes

- `<CategoryPage headingLevel>` and `<CategoryTileGrid reserve>` — two host workarounds handed back to the pair.

  **The title's level is the document's business.** `<CategoryPage>` drew its title as `Typography.Title level={3}` and took no say in it, so a storefront mounting the page under its own `<h1>` — or straight into a router, where the title IS the page's first heading — had one level and no way to ask for another. `headingLevel` (`1 | 2 | 3`, default `3`) moves the tag and nothing else: the words stay whatever the `heading` slot or the category's own name says, and there is still exactly one heading for the page's title.

  **A host-owned fetch can now reserve the tile row's box.** `<CategoryTileGrid entries>` deliberately skips this component's loading arm (the host owns that read), which left a hole: a category page whose children have not landed draws no row at all, and the row then appears and pushes everything below it. `reserve` hands back the box the pair already draws in its own loading arm — the same grid, the same aspect ratio, the same skeletons, the same busy region. `true` holds it while `entries` is `undefined`; `"pending"` holds it whenever the host says its read is in flight, over rows it still has in hand. An empty array stays a real answer and ends the reservation, and past the tile depth cap nothing is reserved, because nothing is expected. Neither state mounts `<CategoryCarousel>`, so a reservation costs no request.

  Measured with dependencies held constant: the `default` bundle 17.43 → 17.52 KB, inside its 17.6 KB ceiling (the skeleton row moved into one shared component, so the reservation reuses the loading arm's box rather than adding a second copy of it).

## 0.22.1

### Patch Changes

- 9b489ef: `<CategoryPage gutter={false}>` — a page inside a shell must not indent itself
  twice.

  `@stapel/shell-react`'s content box now holds the page gutter
  (`--stapel-page-gutter`), and a page adding its own inline padding on top of
  it sits further in than the header above and the footer below. `gutter={false}`
  drops the INLINE half; the block padding stays either way, because the space
  between a shell's header and this page's first line is not the shell's to
  decide. Default `true`, so a page mounted straight into a router with nothing
  around it is exactly where it was. A prop rather than a context read on
  purpose: whether there is a gutter outside is a fact about the composition,
  and the composing surface is the only party that knows it.

## 0.22.0

### Minor Changes

- 83b1caf: `tn_children_pks` is no longer the answer to "does this row have children" —
  stapel-categories 0.20.5 sends `children_pks`/`children_count`, the reader's
  own LIVE set, because the treenode column counts soft-deleted and retired
  rows too. A live "services" root showed the defect exactly:
  `tn_children_pks: "68,67,221"` while only `221` was fetchable, so a rule
  built on the column saw two ghosts and missed a one-child wrapper.

  Every rule in this pair that read `tn_children_pks` for that question now
  reads `children_pks`/`children_count` first: `hasChildren`, `browseStage`,
  `childControl`, `isTransparentWrapper`/`isWrapperAncestor`, the cascade's
  leaf detection (`categoryChildIds`), and `CategoryPage`'s tile/wrapper
  decisions, which all run through the same fallback chain.
  `tn_children_pks` is still read, but only as a FALLBACK for a server
  predating 0.20.5 — a dev build warns by name when a reader falls back to it.

  New export: `categoryLiveChildCount`, the same preference chain as
  `hasChildren` but returning the count itself rather than a boolean, for a
  caller that needs to know "exactly one" (`isWrapperAncestor`). `Category`
  and `CategoryTreeNode` gain the two fields as optional extensions, so a
  build against an older pin keeps compiling.

## 0.21.0

### Minor Changes

- a652ca8: Two additions, one round.

  **An authored transparent node, alongside the structural wrapper.**
  stapel-categories 0.20.4 adds `children_as: "transparent"`: a catalogue
  author marks a level that browsing skips deliberately — its children appear
  where it would, its own page is its parent's — whether or not it has
  siblings. `isTransparentNode` reads the flag; `browseChildren` now splices
  out **every** transparent child among a node's children, not only a lone
  one, replacing each with its own children in place, order kept. The
  structural one-child wrapper rule (`isTransparentWrapper`) is unchanged — it
  is just one more way a single child can qualify. `browseStage` takes an
  optional `parent` argument: a transparent node's own shape is
  `browseStage(parent)`, `"feed"` without one — never `"tiles"` for a node
  that is not a real destination. `childControl` returns `"none"` for a
  transparent node, and `isWrapperAncestor` honours the flag for a breadcrumb
  step regardless of sibling count. `<CategoryCascadeField>` collapses a
  transparent rung the same way, wherever it sits among a rung's options. A
  flagged LEAF is ignored (a leaf cannot be transparent) with a dev-only
  console warning — `children_as` is declared here by hand, ahead of the
  pinned schema, which still lists only `tiles | chips`.

  **The reference's second-level tile, and the «All categories» overflow**
  (owner's ruling, 2026-09-04). `<CategoryTileGrid size="compact">` is a third
  tile anatomy — a horizontal row, name left, small picture right, about half
  the root tile's height for the same width — orthogonal to `density` (the
  phone scroller's column count) and `layout` (scroll vs wrap); a root landing
  keeps `size="regular"`, unchanged. `maxVisible` + `overflow="modal"` caps the
  grid and draws one more tile, «All categories», opening a `SkinDialog` that
  lists every child (not only the hidden ones) as compact, real links, with a
  search box past 20 rows. `<CategoryPage>`'s `"tiles"` arm takes the same
  three knobs as `subcategoryTileSize`, `subcategoryMaxVisible` and
  `subcategoryOverflow`. A storefront applies
  `size="compact" maxVisible={10} overflow="modal"` on every tile page below
  the home.

## 0.20.0

### Minor Changes

- a7b8190: A transparent wrapper's breadcrumb crumb no longer links anywhere.

  The browse rule already skips a one-child import wrapper's own tile
  (`isTransparentWrapper`/`browseChildren`, `catalog/wrapper.ts`) — a
  storefront integrator flagged that its breadcrumb trail (`Услуги →
«Предложение услуг» → group`) still linked the wrapper's crumb, offering a
  destination the rest of the storefront treats as not there.

  `CategoryCrumb` gains `linked` (default `true`); `<CategoryBreadcrumbsBar>`
  renders a `linked: false` crumb as plain text — same typography, no anchor,
  `aria-current` untouched on the real current crumb. The wrapper is detected
  automatically from the trail's own rows via the new `isWrapperAncestor`
  (the same `tn_children_pks` check `isTransparentWrapper` uses, sized to a
  crumb list rather than a tile page's full sibling array — no extra
  request). A host that holds ancestry knowledge the trail cannot supplies
  `unlink` instead — on `<CategoryBreadcrumbsBar unlink>` directly, or via
  `<CategoryPage breadcrumbs={{ unlink }}>` — which REPLACES the automatic
  check for every crumb rather than adding to it.

## 0.19.0

### Minor Changes

- 5e4ee0f: A one-rung import wrapper is now invisible to browsing — the census
  addendum to the browse-stages contract (2026-09-04): `/c/uslugi` has one
  child (an import-only "offer" category) whose own children are the real 34
  groups, a level that exists only because the source catalogue nested a real
  level under a placeholder one.

  New `isTransparentWrapper(children)` (`catalog/wrapper.ts`) is `true` only
  for exactly one child that itself has children — a single leaf child, or two
  or more children, is never a wrapper. It reads the same fields
  `browseStage`/`childControl` already do (`tn_children_pks`, then
  `children_as` surviving a depth cut, via the now-exported `hasChildren`), so
  detecting a wrapper never costs a request. New `browseChildren(children,
grandchildrenOf)` returns `children` unchanged for anything that is not a
  wrapper, the wrapper's own children once `grandchildrenOf` resolves them, or
  `children` unchanged (the wrapper's own single tile) while that resolution
  is still in flight — never an empty page. The rule fires once: a
  wrapper-of-a-wrapper is not chased past its first hop.

  `<CategoryPage subcategories="tiles">` wires this itself: its tiles arm
  (`TileSubcategories`) detects a wrapper for free off the rows it already
  has and, only then, fires one small `useCategoryChildren` read to draw the
  wrapper's children as the page's tiles instead of a single tile pointing at
  an import level nobody may act on. `useCategoryCascade` (and
  `<CategoryCascadeField>` over it) applies the same one-hop merge to
  whichever rung a wrapper lands on — fetched eagerly the moment the wrapper
  is detected, not only after a click on its otherwise-pointless one-option
  select — so `atLeaf`/`trail`/`selected` and the visible rung count all agree
  with what the ladder actually shows.

  Both new functions are exported from the package root, alongside
  `hasChildren` (moved from a `catalog/stage.ts` internal to a shared export
  so `catalog/wrapper.ts` reads the same fallback chain rather than a second
  copy of it).

## 0.18.0

### Minor Changes

- 5734564: `GET {id}/features/` reads the two things stapel-categories 0.20.1 added: a `chips` parent with no features of its own answers the EFFECTIVE schema (the intersection of its children's), and says so via an `X-Effective-From` response header — outside `StapelClient.get`'s reach, so this one read goes over a small raw-`fetch` carve-out (`api/featuresRaw.ts`, the one legal home of `fetch` per `stapel/no-raw-fetch`). `useCategoryFeatures` now resolves to `{ features, effectiveFrom }` and `<CategoryFeatures>`'s bag gains `effectiveFrom: "own" | "children"` plus `divergent: boolean` on each entry; `CategoryFeature` gains an optional `divergent?: true` (declared by hand, ahead of the next contract-pins regen). The new `visibleFeatures(features, { chipPicked })` hides a `divergent: true` row until a chip is picked — exported for the composer and the facet rail, not wired to any host here.

  `useCategoryCascade`/`<CategoryCascade>` (`commit: "stage"`, unchanged otherwise) takes a `partitionChild` option and echoes it back on the bag, so a host's own partition select (drawn beside the cascade, per the browse contract) can feed `visibleFeatures`'s `chipPicked` from the same bag instead of a second piece of state.

  `<CategoryMegaMenu onSelect={(node, kind) => …}>` fires on click/Enter of any item — a rail root (`"root"`), a column's own header link including its `N more` tail (`"child"`), or a third-level link (`"grandchild"`) — additive to navigation, so a host no longer reads `data-category-id` back off the DOM through a delegated listener to learn which row was pressed.

  `<CategoryPage measure>` sets the page's content column width (default `CATEGORY_MEASURE`, `64rem`, now exported from `/default`), replacing a host's `!important` override of a value it could not read back.

- 851cd6b: **`browseStage`'s answer changed — this is a break in behavior, not just an API addition.** The founder's evening correction to the browse contract (2026-09-04) supersedes the earlier reading ("tiles end where the attribute schema begins", i.e. `children_as: "tiles"` draws a tile grid at any depth): tiles are now exactly two levels. `browseStage(category)` returns `"tiles"` **only** for a ROOT with children — reading `tn_parent` / `tn_ancestors_pks` / a `CategoryTreeNode`'s own `path`, whichever the row carries, never a depth number a caller computed elsewhere — and `"feed"` for everything else, including a non-root `chips` parent that the old rule sent to a tile grid. `children_as` no longer decides the stage at all.

  New `childControl(category)` → `"none" | "segmented" | "list"` fills the gap: it is the FILTER a feed page's own rail should show for that category's children — `"none"` when childless, `"segmented"` when `children_as === "chips"` (a single-select chip row over a partition of one template), `"list"` otherwise (a single-select subcategory list with counts). Both functions read `tn_children_pks` before `children_as` before the nested `children` array to answer "does this row have children at all", because a depth-capped `/tree/` read empties `children` on its last level and `children_as` survives that cut where the array does not (the server sends it `null` only where a row truly has nothing to present).

  `<CategoryPage subcategories="tiles">`'s own tiles-vs-cascade decision now calls `browseStage` directly instead of the depth-cap heuristic it used to re-derive, so it cannot drift from the exported rule. `useCategoryCascade`'s `commit: "stage"` (and `<CategoryCascadeField>` over it) is **unaffected in behavior** — it still stops at a leaf or a `chips` parent exactly as before — but now expresses that with `childControl(node) !== "list"` rather than `browseStage(node) === "feed"`, since the latter no longer means the same thing.

## 0.17.0

### Minor Changes

- ecbfe30: categories: the first row of tiles stops waiting on a scrollbar, and a category with a handful of children can fill the row instead of sitting in a scroller-shaped corner

  **`<CategoryTileGrid eagerCount>`** (default 8) marks that many of the leading
  tiles' pictures `loading="eager"` with `fetchPriority="high"` instead of
  `loading="lazy"`. Every tile used to be lazy regardless of position, so a
  whole first row sitting above the fold on the day it renders gave the browser
  no reason to fetch any of it before first paint — a walker measured a home
  page's feed shoved 224px down as the row settled a beat late. The "All" tile
  never spends a slot: it never carries a picture. Past `eagerCount` a tile
  stays lazy, which still matters for `layout="wrap"` or a long mega-menu.

  The art corner now reserves its own shape independently of what ends up
  inside it: the box around a tile's picture, or its monogram when there is no
  picture, is a fixed percentage of the tile at a fixed aspect ratio rather than
  a shrink-to-fit cap around whatever content happens to be there. A tile's own
  height was already fixed by its 4:3 ratio against a definite grid column; the
  art corner no longer has a separate, content-dependent size underneath that.

  **`<CategoryPage subcategoryLayout>`** and **`subcategoryMinTileWidth`** reach
  `<CategoryTileGrid>`'s `layout` and `minTileWidth` from the `"tiles"` arm. A
  category with five children in a wide desktop column read as an empty corner
  under the reference scroller — `subcategoryLayout="wrap"` is the fix, the same
  tiles stretched to fill the row instead. Both default unchanged (`"scroll"`,
  the grid's own `minTileWidth`), so no existing host changes shape.

  The `/default` size budget stays inside the existing 15.5 KB line.

## 0.16.0

### Minor Changes

- 3d471ed: categories: the tile art's three arms each get to decline, the cascade learns the browse contract's commit rule, and the storefront gets back the two slots it was re-implementing

  **A tile's art arms may DECLINE.** `renderIcon` was returned whenever the row
  carried a reference — including when it returned `null`. So a storefront with
  its own glyphs for five roots and `null` for everything else switched the other
  two arms off for the whole catalogue: the seeded `catalog_icon` never drew and
  neither did the monogram, and every unglyphed tile had an empty art corner,
  which reads as a tile that failed to load. The order the README documents is
  now the order the code takes: `renderIcon` if it answers, then an address, then
  the monogram.

  **`resolveIconSrc`** is the new middle arm — `(category) => string | undefined`
  — for a host whose CDN needs a lookup rather than a prefix (`product/<sha256>`).
  It takes the CATEGORY, so a host keyed by row answers from its own store
  instead of copying the catalogue into a fresh `entries` array per render, and
  it keeps the library's `<img>`: lazy, 3:2, `contain`, alt text from the tile's
  own label. What comes back still goes through `categoryIconSrc`, so a resolver
  that hands over a reference or a `data:` URI draws the monogram rather than a
  broken image; `undefined` declines and the row's own field answers.
  `<CategoryPage>` forwards it to its tiles arm.

  **`commit: "stage"` on the cascade** is the composer's rule as the BROWSE
  CONTRACT states it, beside `"leaf"`, which states it from the tree alone. The
  ladder ends at the category that owns a feed — `browseStage(node) === "feed"`,
  a leaf or a `chips` parent — and offers no rung below it. The difference is a
  partition: under `"leaf"` a `chips` parent (`Cars`, with `New` and `Used` under
  it) is refused and the cascade goes on offering a rung of New / Used, which
  presents a FILTER as a level of the tree and leaves the browsing half and the
  posting half of the site disagreeing about what a category is. Under `"stage"`
  the cascade commits `Cars`, offers nothing below it and fires no request for
  it; the partition child is the host's own required select, out of the same
  rows. A LEAF deliberately keeps its speculative rung — that empty answer is the
  server verifying the leaf, which is what `atLeaf` is made of. New
  `blockedReason` value `"has_subcategories"` for the `"stage"` refusal; it takes
  the same wording as `not_a_leaf`, because the two rules differ in what they
  accept, not in what they tell a person to do. Keyboard and aria unchanged.

  **`<CategoryTileGrid layout="wrap">`** is the same tiles with no scroll port:
  `repeat(auto-fill, minmax(min(minTileWidth, 100%), 1fr))`, as many per line as
  the container allows and nothing off screen — the geometry a desktop landing
  needs, and the one a host had to draw itself, tile anatomy and all. `minTileWidth`
  defaults to 240px; the `min(…, 100%)` is what stops a container narrower than
  one tile from scrolling sideways. The default is unchanged: `"scroll"`, the
  reference two-row row with the peeking third column.

  **`<CategoryPage heading>`** replaces the page heading's CONTENT — a node, or a
  function of `{ category, count }` — and never the heading element. A storefront
  whose title is a sentence this pair cannot compose ("Buy a car in Sochi ·
  54 364") had to draw its own above the page's, leaving two headings in one
  document outline. `count` is the page's sub-category count, the only count this
  pair owns.

  The `/default` size budget goes 15.25 → 15.5 KB for the three slots (19 B over
  the old line), with the reason in the entry's own name.

## 0.15.0

### Minor Changes

- 6b1d0ea: categories: the catalogue gets a desktop mega-menu, a browse-stage rule, and tiles that draw the picture the server already resolved

  **`children_as`, and the two page shapes it decides.** Every category node now
  carries a resolved presentation of its children. `tiles` means the children are
  real subcategories and the page is a grid of them; `chips` means they are a
  partition of ONE template — the same attribute set split by a value their name
  expresses (new/used, buy/sell/rent, boys/girls) — so the parent's page is a
  feed with a single-select chip row rather than a level of the tree. Nothing is
  removed from the tree: the children keep their ids, paths and URLs and stay the
  placement target of a listing. `browseStage(category)` folds it into the two
  shapes a storefront actually renders, and reads a row's `tn_children_pks`
  before a nested `children` array — a depth-capped tree read empties that array
  on its last level, and taking that for "leaf" would give a whole level of the
  catalogue the wrong page.

  **`useCategoryTree(depth = 3)`** is `GET /tree/?depth=N`: active nodes, ordered,
  nested, four fields plus `children_as` each, in one server-cached call. The
  alternatives it replaces are one request per branch on the coldest page a
  storefront has, or the whole catalogue table (1.4 MB, twenty seconds measured)
  before the first name can be drawn.

  **`<CategoryMegaMenu>`** draws it: roots with their icons on the left, the
  chosen root's second-level headers on the right, five third-level links under
  each and a tail link to the header past that — a column that grew to the length
  of its longest branch would set the height of the whole panel from one crowded
  category. Hover, focus and the arrow keys select a root, `ArrowRight` steps into
  the pane and `ArrowLeft` comes back, and the rail is a real `menu` of
  `menuitem`s with roving tabindex. Escape and a click outside CALL `onClose`
  rather than hiding the panel: a panel that closed on its own and a trigger that
  still reads "open" are two answers to one question. `minWidth` (default 1024) is
  a guard, not a policy — below it the component renders nothing and asks for
  nothing, and the phone keeps the tile grid with no drawer.

  **A tile draws `catalog_icon` when it is already an address.** The rule was
  never "no `<img>`", it was "never a GUESSED url" — and once a catalogue is
  seeded, `catalog_icon` holds the uploaded asset's own URL, so refusing to draw
  it was refusing to show art the server had already resolved. Three arms now, in
  this order: the host's `renderIcon` (a storefront with hardcoded root glyphs
  keeps them), then an address the row already carries, then the monogram. An
  opaque reference like `catalog/electronics` is still never turned into a URL,
  which is what `categoryIconSrc` decides on its own and under test.

## 0.14.0

### Minor Changes

- 2d65d19: categories: a tile caption breaks instead of hyphenating OR ellipsizing, and the cascade prints its path exactly once

  Both halves of this are the same lesson from the same screenshot: the previous release fixed what was measured and broke the thing next to it.

  **The tile caption.** Turning `hyphens: auto` off removed the invented hyphen, and turning `overflow-wrap: anywhere` down to `break-word` at the same time removed the BREAK — so a caption too wide for a 128px compact tile stopped wrapping and the clamp ellipsized it on its first line instead. Nine letters and a dot is not better than a hyphen. `anywhere` is back and `hyphens: manual` stays: the word breaks in the same place hyphenation would have chosen, the whole caption is readable, and nothing on screen is a character the catalogue author did not write.

  **The cascade prints one path.** Removing the crumb tags took the chosen path from three printings to two, and the second was subtler than the first: every rung below the top was LABELLED with its parent's name, and a rung's parent is, by construction, the chosen value of the rung directly above it — visible, one control away, in a bigger type size. A three-level path therefore read "Electronics / Electronics / Phones / Phones / Mobile phones" on the phone's filter sheet, which is what the walker actually filed (D103; the composer's step 3 was the same shape, D89).

  Only the FIRST rung of a ROOTED cascade keeps a visible heading now, because there the parent is the root the tiles handed over at — a category no rung is showing, so the word is information rather than an echo. Every other rung keeps its heading in `aria-label`, where it was never a duplicate: a select whose only name is its value still announces what it is a choice OF.

  The regression test counts, rather than describing the shape — the shape kept changing while the count stayed at three.

## 0.13.0

### Minor Changes

- 9c8ee74: categories: tile captions stop hyphenating, and the cascade prints the chosen path once

  **`hyphens: auto` was a licence the browser took everywhere.** The argument for it was that a word wider than a 70px tile should break like a book word rather than clip. On the deployed phone landing it hyphenated the four longest root captions mid-word, set a nine-letter one in three lines and a four-word one in six (walker D90, four passes, twice reported as fixed). A hyphen inside a NAVIGATION LABEL is not typesetting: it is a word the reader has to reassemble before deciding whether to tap it, and the three-line clamp already answers the case hyphenation was defending against. Now `hyphens: manual`, which still honours a soft hyphen a catalogue author writes into a name on purpose, and `overflow-wrap: break-word` rather than `anywhere`, so the browser stops preferring a mid-word break to a perfectly good space earlier in the line.

  **The cascade's trail is gone.** A row of closable tags above the selects, one per answered level — redundant with the selects by construction, which this component said out loud and kept anyway, on the argument that popping a tag is one tap where re-opening a select and picking its blank entry is three. That argument had already expired: every rung carries `allowClear`, so the clear button inside the select pops that level in exactly one tap, from the control that is already showing the answer. What the tags bought was a second printing of the path — on the phone's filter sheet, half a screen restating one fact before the first control (walker D103; the composer's step 3 was the same shape, D89). One path, printed once, in the controls that can change it.

  `bag.trail` and `bag.clearFrom` are untouched: the headless answer is unchanged and a host that wants its own trail still has one. What changed is that the default skin no longer draws a second one. `categories-cascade-trail` and `categories-cascade-crumb-<id>` are no longer in the DOM.

## 0.12.1

### Patch Changes

- a9dbe3e: A short cascade rung is read whole, without a gesture.

  `<CategoryCascadeField>`'s option list took antd's 256px default. Against the
  44px touch rows this skin draws that shows five and a half of them and lands
  the seventh flush with the bottom edge — so a ten-option rung, which is what a
  catalogue's TOP level is, renders with no visual cue that three more exist
  behind an eight-pixel scrollbar.

  Two separate walkers read a ten-root catalogue as "seven roots" from that
  view and filed it as missing data. It was not: `GET /categories/carousel/`
  answered all ten in one request, the list is virtualised, and a real finger
  reaches the rest. A scripted `scrollTop` does not, because a virtualised list
  keeps `overflow-y: hidden` on its holder and scrolls itself — so the report
  was measurement, but the affordance it measured was real.

  `listHeight` now fits ten rows. A longer rung still scrolls, and always looked
  scrollable, because there a row is cut by the edge.

## 0.12.0

### Minor Changes

- 940f804: `<CategoryPage>`'s `"pane"` arm on the id path renders the level it already
  holds and never starts the catalogue sync.

  Hosts pass BOTH addresses — `categoryId` (the cheap one) and `slug` (the
  canonical URL) — and the pane arm used to pick `<CategoryTreePane slug>`
  whenever a slug was present, resolving it through the full multi-page
  catalogue sync for rows that `GET {id}/children/` had ALREADY loaded to gate
  the page. Measured on a live classified deployment: ~36 requests and 13.2
  seconds of skeletons under "Subcategories" on a cold desktop landing, while
  the same host's list page drew the widget in 0.4 s from per-level reads. It
  was the last surface still paying for the catalogue with the id in hand.

  - The pane arm now renders the titled list directly from the level the page
    holds when `categoryId` resolved it; `<CategoryTreePane>` remains the
    slug-only fallback, byte-for-byte unchanged.
  - Per-row "N subcategories" chips come from `useCategoryLevels` — one small
    cached children read per row, priming the same cache every other rung
    reads. Rows render immediately; a count not yet landed (or refused) draws
    no Tag and no chevron — quiet, never a skeleton gate, never a zero standing
    in for an unknown. A count that lands >0 draws exactly what the catalogue
    path draws.
  - The row markup is extracted into an internal `<CategoryLevelList>` shared
    by both arms, so the two sources cannot drift apart visually. Not exported:
    hosts compose the pane or the page, not a third bare list.

## 0.11.0

### Minor Changes

- 3e142b0: `<CategoryTileGrid density>` — the owner's ruling on tile size. The reference
  two-row scroller ("cozy", still the default) sizes its column as a fraction
  of the container, which read as huge tiles at 390px and inflated to a wall of
  ~550px tiles inside a wide desktop column. `"compact"` is the dense strip:
  4.4 visible columns AND an absolute 128px cap on the column, so the same
  mount is small on a phone and a modest strip — never a wall — at any width.
  A compact tile is also a different anatomy, not a shrunken cozy one: at
  ~70px the label-corner/art-corner layout collides, so the dense arm draws
  the icon (or monogram) centred on top with a centred two-line hyphenating
  label under it — the dense-grid convention, on a square tile.
  `<CategoryPage subcategoryTileDensity>` passes the same choice through to the
  tiles arm. `TileDensity` is exported. The skin bundle budget moves 13 KB →
  13.25 KB for it.

## 0.10.1

### Patch Changes

- 47b23c8: The tile grid's label wraps instead of clipping. Measured on a live 390px
  classified catalogue: the tile's inner column is ~105px, one root name is a
  single 12-letter unbreakable word wider than that — the two-line clamp
  ellipsized it on its FIRST line — and the longest root name is three lines of
  text in a two-line box. The label now hyphenates under the document's `lang`
  (`hyphens: auto`, with `overflow-wrap: anywhere` as the dictionary-less
  floor), takes a third clamped line, and sits on a slightly tighter line so
  the art corner keeps its room.

## 0.10.0

### Minor Changes

- d7677eb: A phone can reach any leaf, filter by anything the category declares, and get home.

  **`categories-react` — the rootless cascade stops reading the whole catalogue.**
  With no `rootId` and no host-supplied `roots`, the top rung was the category
  LIST endpoint, which has no roots filter: on a live catalogue of 3583 rows that
  was 36 requests, 1.4 MB, and 19.9 seconds before the composer's first select
  existed. Every rung below it costs one `children/` call and a third of a
  second, so the whole cost of the control was that one question. It is now
  answered by `GET /categories/carousel/` — one cached request — projected to the
  rows with no ancestors, which is what a root is. A deployment whose carousel
  names no roots falls through to the catalogue sync, unchanged.

  **`search-react` — an uncounted facet is still a filter.**
  A facet's options came only from the counted buckets, so a slug in
  `facet_meta.skipped` had none, and every surface drops a group with no options.
  On a live cars leaf that meant 26 facetable features declared, 12 counted, and
  14 filters a person could read about in a warning and not use — while `/query`
  accepts `f.<slug>` for every one of them. `buildFacetGroups` now builds an
  uncounted facet's options from the category schema (`config.options`, or the
  answer's own captions), with `count: null` beside each — "nobody counted this"
  is still said, and it no longer decides whether the filter exists. An applied
  value always renders. A `ref_select` whose config is a bare vocabulary pointer
  still has no options here and is still not invented.

  **`search-react` — the skipped-slug notice is opt-in.**
  `FacetPanelPane`/`SearchPage` take `skippedNotice` (default `false`). The
  sentence is the engine's own note about its facet plan; on the live cars leaf
  it rendered as a yellow warning naming forty-two of the category's fields above
  the filters. Same class as the synonym-expansion notice this pair removed
  earlier. `skippedNotice` puts it back for a developer.

  **`shell-react` — a phone has a way home.**
  `phoneChrome="dock"` draws no wordmark (a 390px row cannot hold one and a
  search field), and that left `/` reachable from nowhere: the header's leading
  control is the host's history back arrow, and the dock's tabs are wherever the
  nav manifest points. The header now carries a home MARK — the brand's logo at
  glyph size, or a house where there is none — always a link to `/`, at the head
  of the row. `home={false}` for a host whose own chrome owns that corner.
  `HomeOutlined` joins the nav-icon registry, so a manifest may declare a home
  destination without drawing the fallback square.

## 0.9.1

### Patch Changes

- 2855c8b: Every category link carries its row's ID as `data-category-id`.

  `/c/:slug` has no server-side lookup — `stapel-categories` never overrides
  `lookup_field` and its list takes no `slug` filter — so a cold slug costs the
  whole catalogue (36 requests, 1.4 MB, 23.4 s on a live 3583-row deployment)
  while the id costs two small reads. Every link this pair draws already KNOWS
  the id, because it drew the row; the tiles, the tree pane, the carousel, the
  breadcrumbs and the search hits now pass it through the link seam so a router
  host can carry it into the destination (`<Link state>`, a cache seed) and mount
  `<CategoryPage categoryId>` instead of `slug`.

  A `data-*` attribute rather than a new field on core's `LinkComponentProps`:
  that contract already has the `data-${string}` index signature, so this reaches
  a host component that spreads its rest props and changes no shared type.

## 0.9.0

### Minor Changes

- f69b42b: The catalogue walk is SERVER-DRIVEN: one small request per rung, and the tiles
  hand over to the cascade instead of to nothing.

  Measured on a live 3583-row classified catalogue, the whole-tree sync every
  surface mounted cost **36 requests / 1.4 MB / 20.2 s** before a category picker
  could draw its first select; one rung of `GET {id}/children/` costs **1 request
  / 1-4 KB / 0.25-0.39 s**. The tree is only "already in memory" once somebody
  has waited twenty seconds for it.

  - `useCategoryCascade` reads `GET {id}/children/` per rung and takes the chain
    a deep value implies from that value's own `tn_ancestors_pks`
    (`GET {id}/`, 300 bytes). A ROOTED ladder — the one a category landing
    mounts — never touches the catalogue. The rungs above a pending one stay on
    screen, so the ladder grows downward rather than blanking.
  - `<CategoryPage subcategories="tiles">` past the depth cap now renders the
    CASCADE. It rendered nothing, which on that catalogue left 2924 of 2924
    active leaves — every category that has any characteristics — unreachable by
    browsing from a phone, while the same URL at 1440px descended in three taps.
  - `<CategoryPage categoryId={…}>` is the fast address: two small reads, no
    catalogue. `slug` still resolves against the sync, because the server has no
    slug lookup. `onNarrow` / `narrowValue` report a cascade choice to the host.
  - `<CategoryBreadcrumbs categoryId={…}>` builds the trail from the server's own
    ancestry, one 300-byte read per crumb.
  - New reads: `retrieve` on the API, `useCategory`, `useCategoryRows` and
    `useCategoryLevels`.
  - `useCategoryCascade` takes `roots` — the escape hatch for the one rung the
    server cannot answer (there is no roots endpoint and no `tn_parent` filter).
    Both that and a slug lookup are recorded in MODULE.md as upstream asks.

  Breaking, pre-1.0: the cascade bag and `CategoryCrumb` carry `Category` rows
  rather than built `CategoryNode`s (a node can only come from a whole tree, and
  needing one is the same as needing all of it); `buildCategoryCascade` and
  `cascadeReachedLeaf` take fetched rungs plus a chain of ids; `SubcategoryForm`
  gains `"cascade"`.

## 0.8.0

### Minor Changes

- 3ffadeb: The cascading child selector the tile cap hands over to — `useCategoryCascade`,
  `<CategoryCascade>`, `buildCategoryCascade` and the `<CategoryCascadeField>`
  skin.

  `catalog/tiles.ts` has capped tile navigation at the second level of the tree
  since 0.7.0, on the rule that everything deeper is chosen "as a characteristic,
  through cascading child selectors". The cap was enforced and the selector did
  not exist, so on a live classified catalogue — 3583 categories, 3036 of them
  leaves — only 198 rows were reachable, a level-2 page answered `[]` from
  `{id}/features/` (features resolve by inheritance, so a category whose own rows
  are empty legitimately has none), and its child answered 59. A person was told,
  truthfully, that the category has no characteristics, one tap above the ones
  that make it usable.

  One primitive serves both surfaces, because the owner's model requires the same
  gesture when filtering and when posting. The ladder is a pure function of
  (index, root, cursor) — a rung is derived from the chain above it rather than
  remembered, so changing one cannot leave a stale answer below it. `commit`
  is the only difference between the two: `"any"` for a filter (a category path
  matches as a prefix, so a parent finds its descendants), `"leaf"` for a composer
  (a non-leaf inherits the wrong feature set). Counts per option are a host prop
  and are unfilled: no server can currently answer them, and MODULE.md carries the
  exact shape asked for upstream rather than a number nobody could check.

  `<CategoryPickerField>` is untouched and stays the right control for a search
  across a whole catalogue.

## 0.7.0

### Minor Changes

- 7f23ccf: The catalogue stops offering rows nobody may browse, tiles stop at the second
  level, and a query can reach a category.

  **The browse projection.** `GET /categories/` is a REVISION-SYNC contract and
  is right to keep sending rows a shopper may not see — a consumer that never
  received a row again could never learn it went inactive. So the filter belongs
  on the consumer. On a live classified deployment the list endpoint answers 187
  rows of which 105 are `active: false`, end-to-end leftovers, and every browse
  surface offered them. `isBrowsableCategory` / `browsableCategories` /
  `ADMIN_VISIBILITY` (`catalog/browse.ts`) are the one predicate the tile grid,
  the carousel, the tree walk and the children hook now share: `active`
  (absent means active — the flag is optional and defaults true on the model),
  `deleted` (a tombstone, deliberately still served), and `is_test` read
  defensively off the wire because the pinned schema does not declare it, where
  ABSENT means "not a test row". It never pattern-matches slugs: a heuristic
  that drops `authz-…` also drops the real category somebody named `winter-2026`,
  and a silently deleted branch is a worse failure than a visible test row. The
  sync CACHE still ingests every row — only the projection over it filters, or
  the next delta breaks.

  **Tiles are two levels.** Level 1 on the home screen and a top-level
  category's children on its landing; below that a category is a
  CHARACTERISTIC, chosen through cascading child selectors when filtering or
  posting, not a tile to navigate into. `MAX_TILE_DEPTH` /
  `categoryOffersTileGrid` / `nodeOffersTileGrid` (`catalog/tiles.ts`) are that
  rule as one exported number, so the search and composer surfaces read the
  same one. `<CategoryTileGrid categoryDepth>` renders nothing past the cap —
  not an empty state, because nothing is absent: the sub-categories exist and
  are offered in a different shape — and it returns before mounting the
  carousel bag, so it issues no request it would discard.

  **One subcategory list, not two.** `<CategoryPage subcategories>` takes
  `"pane" | "tiles" | "none"` and MOUNTS exactly one; the other is absent from
  the document rather than hidden. Without it a host wanting tiles had to mount
  the grid as well and hide `<CategoryTreePane>` with its own stylesheet, which
  a live deployment was doing — the same links rendered twice. `"pane"` stays
  the default, so no existing host changes. The page also takes `renderIcon`
  and forwards it to the tiles arm, which otherwise could never draw art.
  `categoryTileEntry` is the row→tile mapping lifted out of
  `<CategoryCarousel>`: it held the only copy of the `carousel_icon` →
  `catalog_icon` → `null` fallback order (with `""` read as absent), and a
  second copy would have drifted on the one detail that is invisible when wrong.

  **A query reaches a category.** `useCategorySearch` /
  `rankCategoryMatches` / `<CategorySearchHits>` match a free-text query against
  the categories the browse projection has ALREADY loaded — exact name, then
  prefix, then substring, case- and diacritic-insensitive, over the localized
  label and the slug, capped. No request per keystroke, and nothing the browse
  projection hides can be reached through it. It is a list of category links,
  not a picker and not a typeahead over the whole tree.

- 7994c0d: `<CategoryPage breadcrumbs>` and a picker that stops printing its label twice.

  **The trail is a deployment's decision.** `breadcrumbs={false}` mounts no
  crumb bar — absent from the document, not covered. Which chrome carries "where
  am I" is a navigation decision and both answers are right: on a desktop the
  trail IS the catalogue's navigation, the only affordance on screen for moving
  back up the tree; on a phone the reference design gives that job to the app
  bar's back arrow, and a crumb row above the title repeats it in a second
  visual language while spending one of four lines above the fold. A live
  classified deployment had exactly that, as a `display: none` under a media
  query with an upstream ask attached — and a host hiding a pair's output with a
  stylesheet is the pair's bug.

  **The picker's visible heading goes.** `<CategoryPickerField>` is mounted
  inside somebody else's form, whose form item already prints "Category" above
  it; a second copy underneath is the same word twice in two type sizes, which
  reads as two stacked controls. It is dropped from the SCREEN and not from the
  accessibility tree — mounted bare, the trigger would otherwise be a button
  whose only name is the value inside it ("Phones, button", with nothing saying
  what Phones is a choice OF), so the name is still authored, still translated,
  and `visuallyHidden` keeps it in the tree, joined to the value rather than
  replacing it. The SHEET keeps its visible title on the same key: there the
  word duplicates nothing, and a dialog with no header is a panel that appeared.

- 8d1e20f: The phone dock stops truncating its labels, stops covering the footer, and the
  phone SERP gets a one-line toolbar instead of four stacked rows.

  **A compact label for a compact chrome.** `NavEntry.shortLabelKey` (core) is an
  optional second i18n key a manifest declares when its menu label cannot fit a
  dock cell. A five-item dock at 390px gives each destination about ten
  characters, and a label written for a menu row ellipsizes mid-word — a
  destination a person has to guess at, which is the one thing a dock must not
  produce. A key and not a length hint, because which words survive the cut is a
  translator's judgement: the useful short form of "Post a listing" is the verb,
  of "My listings" the noun, and no truncation rule finds either. `resolveNav`
  carries it through, `<NavDock>` prints it and keeps the LONG label as the
  link's accessible name; `listings-react` declares one for `compose` and `mine`.
  The dock also drops its inter-cell gap and one inset step — 24px given back to
  five labels — and `scripts/gen-nav-manifest.mjs` validates the new field.

  **The clearance belongs to the page, not the content.** The island is fixed
  over the last thing on the page, and the last thing is the footer. Reserving
  `DOCK_CLEARANCE` on `<Layout.Content>` cleared the final card and left the
  footer's legal links permanently under the island. `<PublicShell>` reserves it
  on the page column instead, and only when `dockRenders(nav)` says an island
  will actually be drawn — a one-entry nav used to get a strip of empty page
  under a dock nobody rendered.

  **A phone toolbar that is one row.** `<SearchResultsPane header="compact">`
  gives the toolbar its own line and puts the count directly above the cards as
  their caption, with the heading visually hidden but still in the document
  outline; the banner shape (heading | count + toolbar) is unchanged and
  remains the default. `<SortSelect compact>` drops the caption and the 200px
  floor so the control shares a row, and moves the blocked `distance` option's
  REASON into the option's own label — on a phone, where that refusal is most
  common, a separate reason row costs a band of viewport above the first result.
  `<FilterChips>` takes `geoChip={false}` for a surface that already states the
  location above it (the phone SERP mounts `<LocationSummaryLine>`, and the two
  together asked about one filter twice), and renders NOTHING when it would be a
  row of one button — a free-text query has no category, so the server returns no
  facet plan, and the row was a lone circle floating between two working filter
  affordances. `<LocationSummaryLine>` says "Filters", not "All filters": that
  end of the row shares 390px with a place name.

  **Tiles say which category they are.** `<CategoryTileGrid>` draws the
  category's own initial where art is missing, instead of a muted disc. A live
  catalogue put nine identical grey discs on one landing — every category there
  carries an empty `carousel_icon`, which is the state every catalogue is in
  until somebody uploads art — and a grid of them reads as nine images still
  loading. A letter cannot be mistaken for a pending image, and every tile
  differs from every other.

  **`visuallyHidden`** (tokens-antd `/skin`) is the fleet's one off-screen-but-
  announced style. It was written twice before, in `calendar-react` and
  `search-react`, and the two disagreed on `clip-path` versus the deprecated
  `clip`; both now import it.

### Patch Changes

- e738b83: Regenerated against the contracts the fleet actually installs.

  `contract-pins.json` moves stapel-search 0.4.0 → 0.7.0 and stapel-categories
  0.7.0 → 0.9.0 — the two pins the freshness gate reported as three and two
  minors behind, and the two versions a live classified deployment now runs. A
  pair regenerated from a stale pin is internally consistent and wrong about the
  wire, which is the whole reason the gate exists.

  What the regeneration brings in:

  - `search-react`'s `GET /suggest` grows `categories[]` — a destination per row
    with its full ancestor path, the number of LIVE listings behind it and a
    `category` string to pass verbatim to `/query`, ranked by that count. The
    answer is now public and conditional (`Cache-Control` + `ETag`), which is
    what makes a per-keystroke read reasonable.
  - `categories-react`'s feature-config union gains `group` — attributes v2's
    container type, whose config holds its children as raw dicts each
    discriminated by its own `type`, plus an optional `repeat`. The pair's
    discriminator contract test pins thirteen members instead of twelve; it
    checks in both directions on purpose, and this is the direction that was
    supposed to fire.
  - `calendar-react` and `search-react` raise their `@stapel/tokens-antd` peer
    floor to the release that first ships `visuallyHidden`, which both now
    import. The monorepo cannot catch that by building — in here every package
    compiles against the workspace peer, never against its own declared floor —
    so only a consumer installing at the floor would have found it, after the
    release.

## 0.6.0

### Minor Changes

- 835526f: **The catalogue gets its phone shape: a two-row tile grid that scrolls sideways, and the category landing's quick-search panel.** `<CategoryTileGrid>` and `<CategoryQuickSearchPanel>`, both on `/default`. `<CategoryCarouselStrip>` is untouched — the desktop row stays exactly what it was.

  **`<CategoryTileGrid>`** is the landing row a phone actually wants: rounded `surface-sunken` tiles in two rows, the label top-left over at most two lines, the art pinned bottom-right, and the third column peeking in so the row says it scrolls without a scrollbar, an arrow or a hint line. It leads with an "All" tile linking `basePath` (default `/c`, the same convention the carousel bag already uses), and `allTile={false}` drops it for a row that is already inside a category.

  It is a second surface rather than a `layout` prop on the strip. The strip is a WRAPPING row of cards; this is a fixed grid with a scroll port, a different reading order and a tile whose two corners are doing different jobs. One component with a mode switch would have been one component nobody could photograph either arm of — and the two share the thing worth sharing, the headless `<CategoryCarousel>` bag, so both rows are the same categories in the same order.

  **Every length is a fraction of the CONTAINER.** The column is `100% / 2.5` of the scroll port minus a gap; the tile's height comes from its aspect ratio. A tile sized in viewport pixels is the wrong size inside every sheet, panel and column that is not the whole screen, and this row is mounted inside all three.

  **The image seam is the strip's seam, plus the arm it was missing.** `carousel_icon` / `catalog_icon` are opaque strings the backend deliberately does not resolve, so this skin still builds no URL and renders no `<img>`: it hands the reference to the host through the same `renderIcon(reference, entry)` contract, and a storefront wires its CDN resolver once for both surfaces. What is new is the ABSENCE arm. On the strip, no resolver means a text tile, which is fine for a row of cards; on a tile whose art corner is half its area it reads as a tile that failed to load. So an unresolved reference — no resolver, or a row that carries none — draws a muted placeholder glyph. Never a guessed path, and therefore never a broken image on a deployment that guessed differently.

  **`<CategoryQuickSearchPanel>`** is the brand-tinted block a category landing puts under its tiles: a heading, one or two field slots, and a full-width button whose label carries the live result count.

  **It knows nothing about search, on purpose.** A category package that imported a search package to draw two selects would put the search client in every host of the catalogue and would decide, for every deployment, which facets a category asks about. Neither is this package's call. So the fields are a slot (`fields`) and the count is a value (`count`), both from the container that owns both halves. `count` is core's `LoadState`, and its ready value is field-for-field what `@stapel/search-react`'s new `useSearchCount()` returns — deliberately the same names, so the two connect with no adapter and no import edge between the packages.

  **Only a ready, countable answer earns a number.** Loading, refused, and a ready count the engine declined to give a number for all render the plain "Show listings" — a button that guesses at a total, or that prints "0" because a count was `null`, is exactly the defect the count contract exists to prevent, and a person can press "Show listings" perfectly well without knowing the number first. A LOWER BOUND gets its own sentence ("Show 500+ listings"), because a floor rendered as a total is the same lie in a shorter form.

  New keys in en/ru/es: `categories.tiles.all`, `categories.quick_search.cta`, and the two plural families `categories.quick_search.cta_count` / `…cta_count_at_least` (four forms in ru, two in en/es, as `Intl.PluralRules` says).

- c887a5a: **`<CategoryTileGrid>` takes an `entries` override, so the tile row can draw rows the carousel endpoint does not serve.**

  The carousel bag answers exactly one question: which categories the operator put on the storefront's FRONT PAGE (`carousel_enabled`, `GET /categories/carousel/`). That is the only question a landing asks. A CATEGORY page asks a different one — what is inside this category — whose answer is `useCategoryTree()`'s children, already in the host's hand and not on the carousel endpoint at all. Without a way in, the second surface either re-implemented the tile geometry or drew the wrong rows, and `/c/transport` showed the same five tiles as the home page.

  `entries?: readonly CarouselEntry[]` is that way in, and when it is given the component asks the server **nothing**: `<CategoryCarousel>` is not mounted, so the override costs no `GET /categories/carousel/` — the request a "swap the bag's data" implementation would still have fired and discarded. A test asserts that from the wire rather than from the rendered rows, which is the only place the difference shows.

  An empty array is a real answer — a category with no children — and draws the same empty state a featureless carousel draws. There is no loading or failed arm for an override, deliberately: the host owns the fetch it drew these rows from, so it owns the two sentences that go with it, and handing this component a `LoadState` would give one load two owners.

  `CarouselEntry` is now re-exported from `/default`. It was already part of a skin caller's vocabulary through `renderIcon`; `entries` makes the caller CONSTRUCT one, and reaching into the headless entry for a type you are handed and asked to hand back is a seam with a step in it.

  No new i18n keys.

## 0.5.1

### Patch Changes

- d1125bc: Regenerated against the attributes-v2 contract pins: stapel-categories 0.7.0,
  stapel-listings 0.10.0, stapel-search 0.3.1.

  What moves in the wire types: `FeatureCompact` and `ResolvedFeature` gain
  `rules`, `description`, `example`, `default`, `hints` and `group` — the form
  metadata an imported catalogue actually carries, which is what
  `<FeatureFields>` draws sections, help lines, placeholders and hints from
  instead of a host's hand-written table; `Category` gains `external_id`; the two
  vocabulary-backed value types (`ref_select`, `ref_hierarchical_select`) appear
  in the type enums; and the error registry gains
  `error.400.feature_invalid_rules`.

  search-react's regen is contract metadata only — the facet mapping for the two
  ref types (`term` / `path`, and no `closed_options` for any config carrying an
  `optionsRef`) is decided server-side in stapel-search 0.3.1 and reaches this
  pair as facet rows, not as a new surface.

## 0.5.0

### Minor Changes

- 456b30a: Attribute types become words, the picker becomes a field, and one failure is
  stated once.

  **C-DEVCOPY, the package's worst.** `categories.features.type` was literally
  `"{type}"`, so a public category page badged its attributes `select`, `int`,
  `bool` — and a host's own `holo_signature`. Every value type this build knows
  now has a translated word (en/ru/es) behind `FEATURE_TYPE_LABEL_KEYS`, and a
  type the table does not carry says "Another kind of detail" instead of its
  identifier. The machine name stays on the element as `data-feature-type`, where
  a test reads it and a person does not. `Required` also stops being drawn in the
  danger token — it is a fact about the field, not a failure.

  **NC-FLEXINBUTTON.** `<Flex justify="space-between">` inside an antd `Button`
  shrink-wraps, so every option row came out centred with the chevron hugging the
  label. The flex lives on the button now, rows are on the 44px floor, and the
  phone TRIGGER is a field: a visible label, the value leading, a caret at the
  end. `defaultOpen` mounts the sheet OPEN — the phone story photographed a closed
  trigger, so the sheet it documents had no pixels.

  **NC-DUPESTATE.** The carousel and the tree read the SAME catalogue, so an empty
  or failed one was drawn twice — two stacked `Empty` blocks, two red alerts each
  with its own retry. `CatalogPage` answers for both parts and renders them only
  when there is something to render. `CategoryBreadcrumbsBar` gains
  `onAbsent="quiet"`, which `CategoryPage` passes: one outage no longer produces a
  bare red sentence with a blue link on top of a designed error panel, and an
  unknown slug is not announced twice. That dead end now carries the "Back to the
  catalogue" link its own hint used to promise without offering.

  Tree rows are whole-row links on the touch floor with a chevron for a branch
  (they were 24px words inside 41px rows); catalogue screens get a measure, so the
  "2 subcategories" chip stops sitting 2,300px from the label it counts; and the
  truncation notice stops explaining cache semantics to a shopper.

## 0.4.0

### Minor Changes

- 80617e9: The catalogue screens become the product: the skin is photographed, the two
  composition seams the `/c` routes ship are reachable, and the upstream
  discriminator fix is climbed.

  **Breaking (pre-1.0 ⇒ minor).** `CategoriesSkinTheme`, `CategoriesSkinThemeProps`
  and the pair's own `ErrorAlert` are gone from `@stapel/categories-react/default`.
  Every surface now wraps itself in `SkinTheme` from `@stapel/tokens-antd/skin` —
  which reads the document's LIVE `data-theme` instead of sampling it once, paints
  its own surface, and raises antd's `controlHeight` to 44px below the tablet
  breakpoint. A host that wrapped a composition of these parts imports `SkinTheme`
  (and `ErrorAlert`) from `@stapel/tokens-antd/skin`.

  **The contract chain, climbed.** `src/api/generated/schema.ts` is regenerated
  against stapel-categories 0.6.1: the `FeatureConfig` discriminator this pair
  FILED as a defect is fixed upstream (stapel-attributes 0.4.7), so the ten
  members now carry their real slugs (`type: "bool"`, not `type: "BoolConfig"`).
  The thirty-line apologia in `src/api/types.ts` is deleted and replaced by two
  derived types — `CategoryFeatureConfig` (the slug-keyed union, narrowable) and
  `CategoryFeatureType` — pinned in both directions by `test/contract.test.ts`.

  **Composition seams.**

  - `CatalogPage` accepts and forwards `renderIcon`, so the `/c` route can draw a
    category icon at all; it could not before, whatever the host did.
  - `CategoryPage`'s unfilled `renderListings` renders `<SlotPlaceholder
name="renderListings">` (named in dev, nothing in production) instead of
    silence in the exact place every listing belongs.

  **Phone.** `CategoryPickerField` is a trigger plus a bottom sheet (`SkinDialog`)
  below the tablet breakpoint and the inline list above it; `surface="sheet" |
"inline"` pins the shape.

  **Also:** a feature's `comment` — the catalogue author's note to the person
  filling the form, previously read by nothing in the fleet — renders under the
  feature name via the new `featureCommentLabel`; the sub-category count is a
  translated plural sentence instead of a hover `title=`; `createCatalogStore`
  takes `onUnpersisted` and warns once instead of silently falling back to an
  in-memory catalogue; every load/empty/error arm goes through the substrate's
  `LoadList` / `LoadBoundary` / `EmptyState` / `ErrorAlert`.

  **Demos.** All seven demos now render `src/default` — the antd skin had never
  been drawn in a story, including both nav-mounted screens. 7 demos / 30
  variants, every one seeded into the query cache so its static render IS the
  state it is named for, each with a declared `step`, at least one `phone`
  variant per component, and `assertVariantsRenderDistinctly` in the suite.
  `demo/_harness.tsx`'s `DemoCard` / `StepBadge` debug chrome is deleted.

  New keys (en/ru/es): `categories.category.unknown_slug_hint`,
  `categories.category.subcategories_count.*` (plural family),
  `categories.picker.choose`, `categories.picker.done`. New exports:
  `CATEGORIES_I18N_PLURAL_KEYS`, `featureCommentLabel`, `UNPERSISTED_WARNING`,
  `CategoryFeatureConfig`, `CategoryFeatureType`.

## 0.3.1

### Patch Changes

- The floor states what the imports already require: `@stapel/core >=0.16.0`

  `LinkComponent` first shipped in `@stapel/core@0.16.0`, and this package has
  imported it since. The declared peer floor still said `>=0.15.0`, which npm
  would have honoured — installing a core with no such export, and failing the
  host's typecheck on a symbol this package's own `.d.ts` references. The
  monorepo cannot see it: in here every package builds against the workspace
  peer, never against its own floor.

## 0.3.0

### Minor Changes

- f6ee27a: `linkComponent`: category chrome that does not reload the page

  Breadcrumbs, the tree and the carousel are nothing but links, and every one of
  them rendered a plain `<a href>`. Inside a router app that is a full page load
  per click — the whole application thrown away and rebuilt to move between two
  categories whose rows are already in memory, which is the entire point of this
  pair's delta-synced, app-scoped catalogue. The storefront could not use the
  chrome at all and named it a gap (Wave D, G-4).

  The pair still carries no router. It takes core's `LinkComponent` — a component
  over a plain `href` — and every skin spells the prop the same way:

  ```tsx
  const RouterLink: LinkComponent = ({ href, children, ...rest }) => (
    <Link to={href} {...rest}>{children}</Link>
  );

  <CatalogPage linkComponent={RouterLink} />
  <CategoryPage slug={slug} linkComponent={RouterLink} renderListings={…} />
  <CategoryTreePane linkComponent={RouterLink} />
  <CategoryBreadcrumbsBar slug={slug} linkComponent={RouterLink} />
  <CategoryCarouselStrip linkComponent={RouterLink} />
  ```

  `<CatalogPage>` and `<CategoryPage>` pass it down to everything they compose.
  Omit it and anchors render exactly as before — a host with no router keeps
  working — and it is the same prop `@stapel/listings-react`'s `<ListingCard>`
  takes.

  One detail was load-bearing: antd's `<Breadcrumb items>` renders its own anchor
  when an item carries `href`, which would have bypassed the seam from inside the
  component it lives in. A crumb's link is now its **title**, and the current
  crumb stays a plain label — a link to the page under your feet is not
  navigation.

  `test/linkComponent.test.tsx` asserts the consequence, not the prop: with a
  `linkComponent`, no `<a href>` is rendered on any of these screens.

## 0.2.0

### Minor Changes

- fdbb686: New pair: `@stapel/categories-react` — the catalogue, assembled and kept fresh
  by the client because the backend does none of those three things.

  `stapel-categories` has no tree endpoint, no slug lookup, and no resolved
  labels. `GET /categories/` returns flat rows ordered by `revision`, with
  django-treenode's ancestry as **comma-joined pk strings**; `lookup_field` is
  never overridden and the list has no slug filter, so `/c/<slug>` cannot be
  asked of the server at all; and `name` arrives as `category.electronics`
  because the module's `DISPLAY_TRANSLATOR` seam is called from `__str__` and the
  admin label cache — never from a serializer. Each of those is now handled once,
  here, instead of being rediscovered per host.

  - **The tree.** `buildCategoryTree` assembles flat rows, orders siblings by
    `tn_priority` descending with a deterministic tie-break, and drops both
    soft-deleted rows and **inactive** ones — `active` is filtered server-side
    only by `/carousel/`. A row whose parent is missing becomes a root instead of
    vanishing with its subtree.
  - **The delta sync.** The module's documented protocol (full GET → store
    `revisions.global_max` → `?min_revision=`) into an app-scoped
    `createRepository`, so a second storefront page costs one small delta instead
    of the whole catalogue. Two rules the documentation omits are implemented
    anyway: `revisions.deleted_ids` is the authoritative tombstone channel
    (the `deleted: true` rows are paginated and a short walk misses them), and a
    multi-page walk pins `max_revision` or a concurrent write shifts its page
    boundaries. A walk stopped by its page budget reports `truncated` and rewinds
    its cursor rather than recording progress it did not make.
  - **Names are keys, and the pair says so.** `categoryLabel` / `featureLabel` /
    `featureOptionsAreKeys` report `key` vs `literal` from `translatable`,
    `translate` and `config.translatable_options`. Resolution goes through the
    HOST's i18n engine and this package ships no category names — a catalogue is
    a deployment's content. An unresolved key renders as the key, deliberately.
  - **The feature schema** is handed to `@stapel/attributes-react` unmodified:
    `CategoryFeature` _is_ its `FeatureDef`, so the same rows feed
    `<FeatureFields>`, `unsupportedTypeGate` and the search pair's facet
    captions. `config` arrives verbatim, so defaults stay where they are owned.

  Surface: `createCategoriesRuntime`/`CategoriesProvider`, `useCategoryCatalog`
  plus four direct reads, the pure `catalog/` layer, five headless bags, and an
  opt-in `./default` antd skin with `CatalogPage` (`/c`) and `CategoryPage`
  (`/c/:slug`, with a `renderListings` slot for the search half). en/ru/es.

  Not in scope: the catalogue admin. Create/update/delete, the bulk endpoints,
  `undelete`, `convert-type` and the four feature-editor operations are all
  `IsStaffUser`; `POST {id}/validate-dto/` is a write in DRF's eyes and 403s a
  visitor; `GET /translation-keys/` is `IsServiceRequest`. `manifest.json` still
  lists the whole contract.
