# @v-office/website-components

The imported UI of a v-office website: React 19 components over `@v-office/website-headless` 1.0. A
site renders them as Astro islands (or server-renders them without a client directive where they need
no script). Every business rule, SDK call and piece of state that survives a re-render lives in
headless; this package renders a headless store's state, calls its actions, and carries the copy, the
`data-part` vocabularies and the CSS.

| Surface                             | Component       | Headless hook         | Binding (here)    |
| ----------------------------------- | --------------- | --------------------- | ----------------- |
| entry to search                     | `SearchBar`     | `createSearchForm`    | `useSearchForm`   |
| curated rentals on brand pages      | `Showcase`      | `buildShowcaseGroups` | none              |
| the search page's results           | `SearchResults` | `createSearchStore`   | `useSearchStore`  |
| calendar and price on a rental page | `Quote`         | `createQuoteFlow`     | `useQuoteFlow`    |
| the checkout                        | `Booking`       | `createCheckoutFlow`  | `useCheckoutFlow` |
| the contact form                    | `ContactForm`   | `createContactForm`   | `useContactForm`  |

Beside the six surfaces: the one `RentalCard` every list renders, eleven rental-page blocks
(`RentalHeading` … `RentalDistances`), `AvailabilityCalendar`, `BookingReturn`, `RichText`, the
satellites of the results (count, sort, filters, chips, quick filters, campaign row, prerendered
listing), and the maps in the separate entry `./map`.

**Peers.** `react >=19`, `react-dom >=19`, `@v-office/website-headless >=1.0.0 <2`,
`@v-office/website-sdk >=2.25.0 <3`, and `maplibre-gl >=5 <6` (optional: only a site that shows a map
installs it). Sites pin exact versions.

## Contents

- [Entry points](#entry-points)
- [Setting up a site](#setting-up-a-site): [stylesheet](#the-stylesheet),
  [site constants](#the-site-constants), [layout](#the-layout), [islands](#islands-and-client-directives),
  [own UI](#own-ui-and-the-site-manifest)
- [Styling](#styling): [tokens and axes](#tokens-and-axes), [restyling parts](#restyling-with-data-part)
- [Copy, locale and voice](#copy-locale-and-voice) · [Events](#events) · [Conventions](#conventions)
- Search: [SearchBar](#searchbar) · [RentalJump](#rentaljump)
- [Showcase](#showcase)
- Results: [SearchResults](#searchresults) · [SearchCount](#searchcount) · [SearchSort](#searchsort) ·
  [SearchFilters](#searchfilters) · [SearchFilterChips](#searchfilterchips) ·
  [SearchQuickFilters](#searchquickfilters) · [OfferCampaignFilter](#offercampaignfilter) ·
  [PrerenderedResults](#prerenderedresults) · [The trailing tile](#the-trailing-tile)
- [RentalCard](#rentalcard)
- Rental page: [common block props](#rental-page-blocks) · [RentalHeading](#rentalheading) ·
  [RentalActions](#rentalactions) · [RentalGallery](#rentalgallery) · [RentalFactList](#rentalfactlist) ·
  [RatingBadge](#ratingbadge) · [RentalDescription](#rentaldescription) ·
  [RentalAmenities](#rentalamenities) · [RentalRooms](#rentalrooms) · [RentalReviews](#rentalreviews) ·
  [RentalProperty](#rentalproperty) · [RentalDistances](#rentaldistances)
- [Quote](#quote) · [AvailabilityCalendar](#availabilitycalendar)
- [Booking](#booking) · [BookingReturn](#bookingreturn)
- [ContactForm](#contactform) · [RichText](#richtext)
- Maps: [common map props](#maps) · [SearchResultsMap](#searchresultsmap) ·
  [SearchMapPill](#searchmappill) · [RentalMap](#rentalmap) · [LocationMap](#locationmap)
- [Bindings and helpers](#bindings-and-helpers)
- [Development](#development)

## Entry points

| Import                 | Contents                                                                                                                        |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `.`                    | the surfaces, the card, the rental blocks, `RichText`, `BookingReturn`, the bindings, every copy table and `*_PARTS` vocabulary |
| `./map`                | the maps; `maplibre-gl` is imported on demand, never when the entry loads                                                       |
| `./css/layers.css`     | the cascade-layer order statement, the first line of a site's stylesheet                                                        |
| `./css/components.css` | every component's rules, compiled into `@layer voffice-components`; a site needs no Tailwind for them                           |
| `./css/map.css`        | MapLibre's own CSS from the site's `maplibre-gl`, layered; the map components' rules are in `components.css`                    |
| `./css/motion.css`     | optional: press dip, arrow hover, heart bursts and overlay view-transition groups (none of them in `components.css`)            |
| `./css/tokens.css`     | the shadcn variable set as a neutral fallback, only for a site without a shadcn theme; documents the optional axes              |

Nothing from headless is re-exported. A site imports headless values and types (`sdkConfigFromEnv`,
`countryOptions`, `favoritesStorageKey`, `SearchContract`, `QUOTE_EVENT`, …) from headless itself.

## Setting up a site

### The stylesheet

```css
/* The layer order FIRST, `properties` included: the package sits below the site's own components
   and utilities, so a site rule on a data-part wins without a specificity fight. */
@layer properties, theme, base, voffice-tokens, voffice-components, components, utilities;
/* (or, as the first line: @import "@v-office/website-components/css/layers.css";) */
@import "tailwindcss";
@import "@v-office/website-components/css/components.css";
@import "@v-office/website-components/css/map.css"; /* where a page shows a map; needs maplibre-gl */
@import "@v-office/website-components/css/motion.css"; /* only where the site wants the motions */
/* @import "@v-office/website-components/css/tokens.css";   only without a shadcn theme */
```

- `properties` must come first. Tailwind puts its `@property` fallbacks (`*{--tw-shadow:…}`) in a
  `properties` layer for browsers without `@property`; declared later, that layer outranks the site's
  utilities and its resets win in older Safari and Firefox. Every package stylesheet starts with this
  same statement, but a site statement that comes earlier still decides.
- `components.css` declares nothing on `:root` and never reads `--color-*`: the site's own Tailwind
  theme is untouched.
- `motion.css` exists because several customers ruled hover zoom and press dips out; without it the
  package has no hover scale, lift or press dip. Everything in it sits inside
  `prefers-reduced-motion: no-preference` and is tuned by `--press-scale` (default `0.97`),
  `--img-arrow-hover-scale` (`1.05`) and `--card-fav-hover`.

### The site constants

Islands cannot receive functions and a component never reads env, so a site writes each decision once
as plain data and passes the same constant to every component that needs it. Two surfaces given the same
constant cannot disagree.

| Constant          | Type                                                              | Passed to                                                             |
| ----------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- |
| `SDK`             | `SdkSetup` (`{ config, options? }`)                               | every SDK-using component (`sdk`)                                     |
| `VOICE`           | `Voice`                                                           | every component (`voice`)                                             |
| `CARD`            | `RentalCardOptions`                                               | `Showcase`, `SearchResults`, `PrerenderedResults`, `SearchResultsMap` |
| `SEARCH_CONTRACT` | `SearchContract` (`backend`, `nightRangeSearch`, `aliases`)       | `SearchBar`, `SearchResults`, `OfferCampaignFilter` (`contract`)      |
| `GUESTS`          | `GuestOptions` (limits, child-age band, default adults)           | `SearchBar`, `Quote`, `Booking` (`guests`)                            |
| `DAY_PRICES`      | `DayPrices`                                                       | `Quote`, `AvailabilityCalendar`, `Booking` (`dayPrices`)              |
| `POLICY_CHOICE`   | `PolicyChoiceMode`                                                | `Quote`, `Booking` (`policyChoice`; they must agree)                  |
| `BOOKABLE`        | `BookableFrom` (`from`, `noticeUntil?`, `contact?`, `precision?`) | `SearchBar` `dates.bookable`, `Quote` `bookable`                      |
| `ADDRESS`         | `RentalAddressMode`                                               | `RentalHeading`, `RentalMap` (`address`; no default)                  |
| `FAVORITES_KEY`   | `favoritesStorageKey(namespace)`                                  | the layout's `enhance()`, every `favoritesKey`                        |
| `PAYMENT`         | `BookingPayment`                                                  | `Booking` (`payment`; `methods` has no default)                       |

```ts
// src/data/surfaces.ts
import type { BookingPayment, RentalCardOptions, Voice } from "@v-office/website-components";
import type {
  DayPrices,
  GuestOptions,
  PolicyChoiceMode,
  RentalAddressMode,
  SdkSetup,
  SearchContract,
} from "@v-office/website-headless";
import { favoritesStorageKey, sdkConfigFromEnv } from "@v-office/website-headless";

// Each env name spelled out (the headless README lists them): Vite inlines only the names it sees.
export const SDK: SdkSetup = {
  config: sdkConfigFromEnv({
    CMS_BACKEND: import.meta.env.CMS_BACKEND /* , … every SDK_ENV name */,
  }),
};
export const SEARCH_CONTRACT: SearchContract = { backend: "v10" };
export const VOICE: Voice = "sie";
export const CARD: RentalCardOptions = { favorite: "inline" };
export const GUESTS: GuestOptions = { limits: { pets: 1 }, defaultAdults: 2 };
export const DAY_PRICES: DayPrices = "rounded";
export const POLICY_CHOICE: PolicyChoiceMode = "rental-page";
export const ADDRESS: RentalAddressMode = "town";
export const PAYMENT: BookingPayment = { methods: ["transfer"] };
// Keep the namespace expression the site used before, its fallback included: a changed key empties
// every guest's Merkliste. An empty namespace throws.
export const FAVORITES_KEY = favoritesStorageKey(import.meta.env.PUBLIC_STORAGE_NS);
```

Facts about the business are required props with no default: routes (`action`, `rentalHref`,
`checkoutHref`, `returnPath`, `legal.*.href`), the search `contract`, `payment.methods`, `supportEmail`,
the address decision and the favourites key. Presentation has the fleet majority as its default. A
difference between sites is a prop whose union names what it is (`dayPrices` is `"rounded"`, `"exact"`
or `"none"`, never a site's name), set at the composing page and recorded in the site's `DECISIONS.md`. The SDK setup is
serialized into every island that takes it, so pass only the options the site uses.

### The layout

Server-rendered markup (cards, the rental blocks, a showcase without a directive) is bound by headless
`enhance()`, called once in the layout:

```astro
<body data-favorites-key={FAVORITES_KEY}>
  <slot />
  <script>
    import { enhance } from "@v-office/website-headless/enhance";

    let dispose: (() => void) | undefined;
    document.addEventListener("astro:page-load", () => {
      const storageKey = document.body.dataset["favoritesKey"] ?? "";
      dispose = enhance(document, { favorites: { storageKey } });
    });
    document.addEventListener("astro:before-swap", () => dispose?.());
  </script>
</body>
```

Without Astro's `ClientRouter`, call it once after the page loads. It binds photo strips, the gallery,
the description and amenity dialogs, reveals, clamps, share buttons and the Merkliste hearts. An island
binds its own subtree after hydration (`useEnhance`); binding twice is a no-op. Prerendered search pages
also inline headless `resultsQueryFlagScript()` in `<head>`.

### Islands and client directives

| Component                                                  | Directive                                                | Why                                                                              |
| ---------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `SearchBar`, `RentalJump`                                  | `client:load` (`client:idle` in a nav)                   | the submit stays disabled until the form has read the URL                        |
| `Showcase`                                                 | `client:visible`, or none                                | tabs, rotation and `favoritesKey` need the island                                |
| `SearchResults` and its satellites                         | `client:load`                                            | the store searches after hydration                                               |
| `PrerenderedResults`                                       | none, ever                                               | plain HTML for crawlers; the island removes it                                   |
| `RentalCard`, the rental blocks, `RatingBadge`, `RichText` | none                                                     | complete static HTML; `enhance()` binds what moves                               |
| `Quote`                                                    | `client:visible` (card), `client:media` (the bar)        | one flow per rental, shared by every placement; the bar's query is `barBelow`'s  |
| `AvailabilityCalendar`                                     | `client:visible`                                         | joins the rental's quote flow                                                    |
| `Booking`, `BookingReturn`, `ContactForm`                  | `client:load`                                            | they read the URL or session after mount; the contact submit waits for hydration |
| `SearchResultsMap`, `SearchMapPill`                        | `client:load`                                            | the fullscreen layer must work at every width, where the inline map is hidden    |
| `RentalMap`, `LocationMap`                                 | `client:visible`; `client:idle` with `fullscreen="only"` | `fullscreen="only"` renders no inline map to become visible                      |

Every prop is serializable except the documented `ReactNode` slots (`SearchResults` and
`PrerenderedResults` `trailingTile`, `Booking` `header` and `summaryFooter`), which a page fills with an
Astro named slot. An Astro island renders as `display: contents`, so a grid that should treat an island
as one cell wraps it in an element.

**The dev server needs one Vite setting.** An island whose component comes from this package loads it
from `node_modules`, and Vite's dev server pre-bundles only what the site's own files import. Base UI's
store imports the CommonJS `use-sync-external-store/shim`, which then reaches the browser unconverted:
each such island fails to hydrate in `astro dev` (and so in the studio preview) with "doesn't provide an
export named: 'useSyncExternalStore'". The build converts it and is unaffected, and a site's own UI over
headless is unaffected (its island is the site's file). Pre-bundle the shim in `astro.config.mjs`:

```js
vite: {
  optimizeDeps: {
    include: [
      "@v-office/website-components > @base-ui/react > @base-ui/utils > use-sync-external-store/shim",
      "@v-office/website-components > @base-ui/react > @base-ui/utils > use-sync-external-store/shim/with-selector",
    ],
  },
},
```

### Own UI and the site manifest

A site's build derives `dist/site-manifest.json` from its imports (`@v-office/site-manifest`): importing a
surface's component makes that surface `imported`; importing its headless hook or the binding from here
without the component makes it `custom`. A site that needs another structure copies a component into its
own code, owns the copy, and its next build reports `custom`. The rental blocks, the maps, `RentalCard`
alone, `BookingReturn` and `AvailabilityCalendar` are not surfaces and are not reported.

## Styling

### Tokens and axes

The token contract is the shadcn/ui theme: `--background`, `--foreground`, `--card`,
`--card-foreground`, `--popover`, `--popover-foreground`, `--primary`, `--primary-foreground`,
`--secondary`, `--secondary-foreground`, `--muted`, `--muted-foreground`, `--accent`,
`--accent-foreground`, `--destructive`, `--border`, `--input`, `--ring`, `--radius`, and
`--font-heading` (falls back to `--font-sans`). A shadcn site needs no token work; `css/tokens.css`
declares a neutral set in `@layer voffice-tokens` for a site without one.

Optional axes refine roles and shapes. Each is read with its fallback at the point of use and is never
declared on `:root` by the package (a value there would freeze a fallback such as `var(--primary)`, and
the site's `.dark` block could no longer reach it); a site sets an axis where it sets its theme.

| Axis                                                     | Used for                                                      | Fallback                            |
| -------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------- |
| `--selection`, `--selection-foreground`                  | picked days, active chips and toggles, selected options       | `--primary`, `--primary-foreground` |
| `--primary-hover`                                        | the hover step of the one conversion action                   | `--primary` at 90 %                 |
| `--offer`, `--offer-foreground`                          | offer badges (never a price)                                  | a green pair at ≥ 4.5:1             |
| `--mark`, `--mark-foreground`                            | a map pin highlighted from the list                           | `--foreground`, `--background`      |
| `--accent-text`                                          | field labels and accent captions                              | `--muted-foreground`                |
| `--radius-card`                                          | containers: cards, panels, dialogs                            | `calc(var(--radius) * 1.4)`         |
| `--radius-pill`, `--radius-control`                      | labelled one-line controls, icon-only controls                | `9999px`                            |
| `--radius-label`                                         | labels (below)                                                | `--radius-pill`                     |
| `--radius-badge`                                         | rating badges                                                 | `calc(var(--radius) * 0.8)`         |
| `--shadow-card`                                          | card and popup elevation; `none` removes every package shadow | a soft shadow                       |
| `--shadow-overlay`                                       | popovers, select lists, the calendar's bubbles                | `--shadow-card`, then their own     |
| `--text-caption`                                         | the smallest text the package renders                         | `0.75rem`                           |
| `--card-image-aspect`                                    | card photos                                                   | `5 / 3`                             |
| `--showcase-item-width`                                  | a showcase carousel item                                      | `min(30rem, 82vw)`                  |
| `--results-card-min`                                     | the results grid's minimum card width                         | `18rem`                             |
| `--search-hero-label-weight`, `--search-hero-value-size` | the hero search bar's field labels and values                 |                                     |
| `--search-panel-offset-top`                              | the room a picker popover keeps above itself (a sticky nav)   | `0px`                               |
| `--cal-months-gap`, `--cal-nav-duration`                 | the calendar's month gap and its month-paging animation       |                                     |

`--primary` is the one conversion action per view; a site whose action colour must stay exclusive sets
`--selection` for every other active state.

`--radius-pill` shapes what is pressed: the search submit, filter pills and chips, tabs, the campaign
row. `--radius-label` shapes what only says something: card pills and photo badges, fact pills, the
quote's offer chip, map price pins, the days and nights badge of both calendars, photo counters and
the filter count. It falls back to `--radius-pill`, so "buttons square, labels round" is two lines:
`--radius-pill: 0.25rem; --radius-label: 9999px;` (with `--radius-control` for the icon buttons).
A no-shadow site sets `--shadow-card: none`; the overlays fall back to it. `--shadow-overlay` is for a
site whose floating layers and cards differ (flat cards, lifted popovers).

### Restyling with data-part

Every element a site may restyle carries a `data-part`, and state is an attribute (`data-state`,
`data-open`, `data-selected`, `data-kind`, …), never a class. A boolean state attribute is present when
on and absent when off (`[data-open]`). Each area exports its vocabulary as a constant
(`SEARCH_BAR_PARTS`, `QUOTE_PARTS`, …, listed per component below); renaming or removing a part is a
major release. The package rules sit in `voffice-components`, so a rule in the site's `components` or
`utilities` layer wins:

```css
@layer components {
  [data-part="search-submit"] {
    border-radius: 0;
  }
  [data-part="rental-card"][data-surface="outlined"] [data-part="rental-card-title"] {
    font-family: var(--font-heading);
  }
  [data-part="contact-form"] [data-part="field-label"] {
    text-transform: uppercase;
  }
}
```

Shared vocabularies: `FIELD_PARTS` (`field`, `field-label`, `field-optional`, `field-control`,
`field-hint`, `field-error`, scoped by a surface root such as `contact-form` or `checkout`),
`PRIMITIVE_PARTS` (drawer and dialog chrome, `select`, `stepper`, `popover-positioner`, `skeleton`,
`glyph`, `emphasis`), and headless `ENHANCE_PARTS` (strips, layers, reveals, clamps, share, favourite,
gallery) and `CHECKOUT_PARTS` (the checkout's legal parts). The kits' behaviour and gate markers
(`data-result-card`, `data-rental-stats`, `data-fav*`, `.card-images`, `data-img-*`, …) stay on the same
elements: site scripts and the blueprints gates read them.

Breakpoint props (`collapseBelow`, `gridFrom`, `mosaicFrom`, `overlayActions: { below }`) take the
package's compiled widths: `md` 48rem, `lg` 64rem, `xl` 80rem. A site whose own breakpoints differ picks
the nearest. There are no container queries; the results grid and the showcase track follow width
tokens.

## Copy, locale and voice

Every component that prints words of its own takes (`RichText` prints only the PMS's text and takes
none; `RentalCard` takes them for standalone use only):

- `locale`: `"de-DE"` (default) or `"en-US"`. It decides the default copy, number and date formats, and
  the language the PMS answers in (every headless call carries it).
- `voice`: `"neutral"` (default), `"du"` or `"sie"`: how German copy addresses the reader. Neutral German
  addresses nobody ("Bitte das Alter jedes Kindes angeben"); `du` and `sie` overlay only the keys where an
  address reads naturally. English ignores it. A site sets `voice` once instead of rewriting strings.
- `copy`: a shallow `Partial` of the component's own strings, applied last.
- `shared` (where it renders pickers or cards): `SharedCopyOverrides`, one level deep per group:
  `dates`, `guests`, `region`, `sheet` (the pickers), `card` (every rental card), `rental` (distance
  labels, adjacency statements, "Hunde willkommen", review counts), `common` (close, back, next, apply,
  reset, loading, retry, `optionalSuffix`, `newTabHint`).

```astro
<Quote
  client:visible
  sdk={SDK}
  rental={quoteRental}
  checkoutHref={`/buchen/${rental.slug}`}
  voice="sie"
  copy={{ continueToBooking: "Weiter zur Buchung" }}
  shared={{ guests: { pets: "Hunde" } }}
/>
```

Every table is exported as a `CopyTable<T>` (`{ "de-DE": T, "en-US": T, voices: { du, sie } }`):
`DEFAULT_<COMPONENT>_COPY` per component, `SHARED_COPY` for the shared groups, so a site can seed its
translation namespace from them. `resolveCopy(table, locale, voice, overrides)` and
`resolveShared(locale, voice, overrides)` resolve them the way the components do. Values are strings;
`{token}` templates are filled with `fill` (headless `fillTemplate`), and an unknown token stays visible.
Links are always the page's: a bilingual site passes each language's routes.

### Typed overrides

`copy` and `shared` are typed: an unknown key or a non-string value fails `astro check`, so the build
is refused and the previous one stays live. A key that takes tokens is typed `Template<…>` with the
tokens the component fills (`moreCount: Template<"count" | "label">`), all of them optional. A string
literal fits any key, so the props do not check which tokens a string uses; write the site's overrides
through headless `defineCopy`, which does:

```ts
// src/data/copy.ts
import { defineCopy } from "@v-office/website-headless";
import type { SharedCopyOverrides, ShowcaseCopy } from "@v-office/website-components";

export const SHOWCASE_COPY = defineCopy<ShowcaseCopy>()({ moreCount: "Alle {count} {label}" });
export const SHARED_COPY_OVERRIDES = defineCopy<SharedCopyOverrides>()({
  guests: { petMany: "{count} Vierbeiner" },
});
// defineCopy<ShowcaseCopy>()({ moreCount: "Alle {cuont}" })
//   ✗ "{cuont} is not a token of this string"
```

Every default table is checked the same way inside the package, and `fill` / `fillParts` need a value
for every token a key declares, so a token the code does not fill cannot ship in a default.

## Events

Islands take no callbacks, so the surfaces dispatch `CustomEvent`s on `window` that a site may forward to
its (consented) analytics. The detail's `type` discriminates; it never carries personal data. The names
and detail types are headless constants.

| Event               | Constant (headless) | Dispatched by                                | `detail`                                                                                            |
| ------------------- | ------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `voffice:quote`     | `QUOTE_EVENT`       | `Quote`, `useQuoteFlow`                      | `{ type: "rate-fault", rentalId, stayKey, code }`, once per stay                                    |
| `voffice:checkout`  | `CHECKOUT_EVENT`    | `Booking`, `useCheckoutFlow`                 | `{ type: "rate-fault", rentalId, code }`, `{ type: "booked", … }`, `{ type: "payment-started", … }` |
| `voffice:contact`   | `CONTACT_EVENT`     | `ContactForm`, `useContactForm`              | `{ type: "sent", subject }`                                                                         |
| `merkliste:changed` | `FAVORITES_EVENT`   | the layout's `enhance({ favorites })` binder | `{ id, saved }`                                                                                     |

`booked` carries `rentalId`, `bookingNumber`, `kind` (`"booking"` or `"option"`) and `payable` (false when
nothing can be paid online, so a site may send the guest to its return page itself); `payment-started`
carries `bookingNumber` and `method` as the browser leaves for the provider.

The site dispatches one event itself: `voffice:consent` (`CONSENT_EVENT`, from here) for maps with
`loadOn="consent"` ([Maps](#maps)). A site's own UI over a headless flow dispatches the same shapes itself
(headless hands events to `onEvent`; only the components and bindings dispatch).

```ts
import { CHECKOUT_EVENT, type CheckoutEventDetail } from "@v-office/website-headless";

window.addEventListener(CHECKOUT_EVENT, (event) => {
  const detail = (event as CustomEvent<CheckoutEventDetail>).detail;
  if (detail.type === "booked") track("booking", { kind: detail.kind, payable: detail.payable });
});
```

## Conventions

One name per concept across every component (`lib/conventions.ts`):

| Name                                  | Values                                                           | Meaning                                                                                  |
| ------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `headingLevel`                        | `1`–`6`, `"none"` (`HeadingLevel`; its element `HeadingTag`)     | the level of a component's own heading; `"none"` keeps the text in a `<p>`               |
| `glyphs` (+ `glyphMap`)               | `"none"`, `"default"`, `"mapped"`                                | a glyph before each item: none, the block's own, or the map's entry over the block's own |
| `…From`, `collapseBelow`, `{ below }` | `"md"`, `"lg"`, `"xl"`                                           | the compiled breakpoints (48, 64, 80 rem)                                                |
| `whenEmpty`                           | `"drop"`, `"disable"`, `"show"`                                  | what a filter option that matches nothing does                                           |
| `loadOn`                              | `"near"`, `"click"` (default), `"consent"` (+ `consentCategory`) | when third-party content (map tiles) may load                                            |
| `contract`                            | `SearchContract`                                                 | how the search runs on this backend; one value for bar, results and campaign row         |
| `guests`                              | `GuestOptions`                                                   | `limits.pets`: 0 no dogs, 1 a yes/no toggle, more a stepper, everywhere                  |
| `storeKey`                            | string, default `"search"` (`DEFAULT_STORE_KEY`)                 | the page's one search store; every results island with the same key reads it             |

Glyphs come from a closed, reviewed vocabulary (headless `Glyph`), drawn as inline SVG (`GlyphIcon`,
`data-part="glyph"`, `data-glyph="<name>"`), always `aria-hidden` beside a text that says the same. The
package makes no third-party request of its own (no icon font, no hotlinked image); map tiles are the
one exception and wait for `loadOn`.

## SearchBar

The entry to search: a native GET form to the site's search route. Its hidden inputs are headless
`createSearchForm().pairs()`; it owns the keys of the fields it renders (period, party, region) and
carries every other pair of the URL (sort, filters, property, campaign, site keys). It never calls the
SDK. The submit stays disabled until the form has read the URL after hydration, so a tap before
hydration cannot send an empty query and drop the carried scope.

From `collapseBelow` up it is a row of fields, each opening a panel; below it one trigger opens a sheet
(`mobile="steps"` or `"scroll"`), or the fields stack in place (`"inline"`). Use `variant="hero"` on
entry pages and `variant="search"` on the page whose URL it mirrors.

| Prop            | Values                                                           | Default                                 | Meaning                                                                                    |
| --------------- | ---------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `action`        | string                                                           | required                                | the search route the form lands on; may carry a `#fragment`                                |
| `contract`      | `SearchContract`                                                 | required                                | the site's `SEARCH_CONTRACT`                                                               |
| `fields`        | `"region"`, `"dates"`, `"guests"`, `"pets"`                      | `["region", "dates", "guests"]`         | field order; `"region"` only with `regions`; `"pets"`: the dog control as a field          |
| `regions`       | `RegionOption[]` (`value`, `label?`, `count?`, `group?`)         | none                                    | absent or empty: no region field (a `?region=` scope still rides along)                    |
| `regionSelect`  | `"multi"`, `"single"`                                            | `"multi"`                               | multi is OR with a "clear"; single leads with `copy.regionAll`                             |
| `dates`         | `SearchBarDates`                                                 | see below                               | the date picker's options                                                                  |
| `guests`        | `SearchBarGuests`                                                | headless defaults                       | the site's `GUESTS` plus `showDefault` and `childAges`                                     |
| `preset`        | query string                                                     | none                                    | a landing page's starting search, applied while the URL owns none of the bar's keys        |
| `variant`       | `"hero"`, `"search"`                                             | `"hero"`                                | hero: label over value; search: glyphs, skeletons until the URL is read                    |
| `glyphs`        | `"none"`, `"default"`                                            | `"default"` on search, `"none"` on hero | leading field glyphs                                                                       |
| `panel`         | `"popover"`, `"dialog"`, `"auto"`                                | `"popover"`                             | the desktop panel frame; `"auto"` is a dialog when the room beside the anchor is too small |
| `panelSide`     | `"bottom"`, `"top"`                                              | `"bottom"`                              | which way a popover opens; it never flips (`"top"` for a bar low in a tall hero)           |
| `panelAnchor`   | `"field"`, `"bar"`                                               | `"field"`                               | what a popover is anchored to and as wide as                                               |
| `flow`          | `"fields"`, `"stepped"`                                          | `"fields"`                              | stepped: a titled panel per field whose footer walks to the next and ends in the submit    |
| `mobile`        | `"steps"`, `"scroll"`, `"inline"` (`SearchBarMobile`)            | `"steps"`                               | below `collapseBelow`: a stepping sheet, one scrolling sheet, or stacked fields            |
| `collapseBelow` | `"md"`, `"lg"`, `"xl"`                                           | `"md"`                                  | where the row becomes the mobile form                                                      |
| `rentalJump`    | `SearchBarRentalJump` (`RentalJump` props but `locale`, `voice`) | none                                    | the rental-name combobox after the fields (desktop)                                        |
| `filters`       | `SearchFilters` props without `locale`, `voice`, `compact`       | none                                    | the compact filter trigger of the page's store (desktop)                                   |
| `shared`        | `SharedCopyOverrides`                                            | none                                    | the pickers' groups `dates`, `guests`, `region`, `sheet`                                   |

`dates` (`SearchBarDates`): `flexSteps` (± chips in days, default `[0, 1, 2, 3, 7, 14]`; `[]` or `[0]`
hides the row; on v9 only with `contract.nightRangeSearch`), `flexibleStays` (`"weekend"`, `"week"`,
`"month"`, default all; `[]` removes the Flexibel tab; v10 only), `nightRangeRow` (the Min./Max. Nächte
row, default `false`; whether the backend prices a window is the contract's `nightRangeSearch`),
`minStayNights` (default `1`; closer departure days are disabled with the reason), `bookable` (floors the
picker at `from` and shows the notice with `contact` until `noticeUntil ?? from`; below).

**The not-bookable-yet notice** is one rendering in both places that take the site's `BOOKABLE`
(`SearchBar` above its calendar, `Quote` above its own): the sentence, then `contact` as a link on its
own line. A sentence that leads into the link places it with a `{contact}` token (`deferredNotice` and
`bookableFromNotice`, e.g. "… rufen Sie uns bitte an: {contact}."): the link then sits inline there,
and the token is dropped when there is no `contact`. `{date}` in the sentence names `from` as `precision` says: `"day"` (default, "1. März 2027"),
`"month"` ("März 2027") or `"year"` ("2027"), so `{ from: "2027-01-01", precision: "year" }` reads
"Online buchbar ab 2027." without a copy override. The floor stays the day.

`guests` (`SearchBarGuests`, extends `GuestOptions`): `limits` (headless `DEFAULT_GUEST_LIMITS`: 16
adults, 6 children, 5 babies, 4 dogs), `childAge` (default 2–17), `defaultAdults` (default 2),
`showDefault` (the closed field shows "2 Gäste" instead of the prompt, default `false`), `childAges`
(`"required"` default: a child without an age stops the submit and opens the guest panel; `"optional"`).
`fields` with `"pets"` while `limits.pets` is 0 is a spec error and throws.

**The pickers** are internal and render inside the bar's panels or sheet steps; every rule is headless's
(`DateSearch`, `GuestSearch`). The date panel has the Datum/Flexibel tabs, a range calendar (two months
side by side, one in a narrow dialog, a scrolling year in the sheet), the ± chips and night-range row, the
flexible stays and month strip, and the not-bookable-yet notice. The guest panel has rows for adults,
children (an age select each), babies and dogs, each only when offered. The region panel is a group of
`aria-pressed` buttons grouped by `group` with optional counts. The mobile sheet has one stable
Weiter/Suchen button (a submit swapping in under the finger submitted taps meant for "Weiter").

**Parts** `SEARCH_BAR_PARTS`: root `search-bar` (`data-variant`, `data-resolved`, `data-collapse-below`,
`data-mobile`), `search-form` (`data-layout="row|sheet|inline"`), `search-field` (`data-field`,
`data-open`, `data-empty`) with `search-field-label`, `search-field-value` (`data-skeleton` and the gate
marker `data-skeleton-field` while the URL is unread), `search-field-icon`; `search-submit`
(`data-pending`); `search-panel` (`data-panel`, `data-field`), `search-panel-header`,
`search-panel-footer`, `search-panel-action` (`data-kind="back|clear|cancel|next|save|apply|reset"`);
`calendar`, `calendar-day` (`data-selection`, `data-past`, `data-disabled`, `data-in-preview`),
`calendar-nights-badge`; `date-deferred-notice`, `date-mode-tabs`, `date-flex-steps`, `date-flex-step`,
`date-night-range`, `date-flexible-stays`, `date-month-strip`, `date-month` (chips carry `data-active`);
`guest-row` (`data-guest`), `guest-stepper`, `child-age`, `ages-missing`; `region-option`
(`data-selected`), `region-group`, `region-clear`; `search-sheet-trigger`, `search-sheet`,
`search-sheet-step` (`data-step`), `search-sheet-footer`.

**Copy** `DEFAULT_SEARCH_BAR_COPY` (`SearchBarCopy`): `regionLabel` / `datesLabel` / `guestsLabel`
("Wohin", "Wann", "Wer"), the placeholders (prompts, never an invented place or count), `submit`
("Suchen"), `sheetTrigger` ("Unterkunft suchen"), `deferredNotice` ("Online buchbar ab {date}.", `{date}`
at `bookable.precision`),
`agesMissing` (voiced), and the stepped flow's `clearDates`, `cancel`, `save`, `datesTitle`,
`guestsTitle`. Shared groups: `dates` (`DatesCopy`), `guests` (`GuestsCopy`; dogs are "Hunde", never
"Haustiere"), `region`, `sheet`.

**Events** none.

```astro
---
import { SearchBar } from "@v-office/website-components";
import { BOOKABLE, GUESTS, SEARCH_CONTRACT, VOICE } from "../data/surfaces";
---
<SearchBar
  client:load
  action="/suchen"
  contract={SEARCH_CONTRACT}
  voice={VOICE}
  regions={[{ value: "Altstadt", count: 12 }, { value: "Hafen", group: "Am Wasser" }]}
  guests={{ ...GUESTS, showDefault: true }}
  dates={{ bookable: BOOKABLE, nightRangeRow: true }}
  panelSide="top"
/>
```

### RentalJump

The "Name der Unterkunft" combobox (APG pattern): type a name, a place or an object number; arrows move
through the hits; Enter or a click opens the rental's own page. Free text never submits anything (the
live search has no name filter). Standalone in a nav, or inside the bar (`SearchBar rentalJump`).

| Prop     | Values                                                 | Default  | Meaning                                                                           |
| -------- | ------------------------------------------------------ | -------- | --------------------------------------------------------------------------------- |
| `source` | `{ rentals: RentalIndexEntry[] }` or `{ url: string }` | required | inline (a bar on a search page), or a static index fetched on first focus (a nav) |
| `limit`  | number                                                 | `8`      | hits shown                                                                        |

Entries come from headless `rentalIndexEntries(rentals, href)` at build, with the site's own hrefs.

**Parts** `RENTAL_JUMP_PARTS`: `rental-jump` (root with label and input; `data-state`, `data-open`),
`rental-jump-list` (the popup), `rental-jump-option` (`data-active`).
**Copy** `DEFAULT_RENTAL_JUMP_COPY`: `label`, `placeholder` ("Name oder Objektnummer"), `empty`, `more`
(`{count}` not shown), `loading`, `failed` (the next focus retries).

```astro
<RentalJump client:idle source={{ url: "/rental-index.json" }} voice={VOICE} />
```

## Showcase

Curated rentals on brand pages: one or more groups (tabs) over a carousel or a grid of `RentalCard`s.
Selection happens at build in headless (`buildShowcaseGroups`, `selectRentals`, `selectRelated`); the
component renders, switches groups, steps the row and runs the rotation timer. Every card is in the HTML,
rotated-out tiles included (`hidden`). Without a client directive the layout's `enhance()` makes one
group fully work; tabs, rotation and `favoritesKey` need the island.

| Prop           | Values                            | Default                    | Meaning                                                                                 |
| -------------- | --------------------------------- | -------------------------- | --------------------------------------------------------------------------------------- |
| `groups`       | `ShowcaseGroup[]`                 | required                   | from `buildShowcaseGroups` at build; one group: no tabs; none: renders nothing          |
| `layout`       | `"carousel"`, `"grid"`            | `"carousel"`               | the row                                                                                 |
| `gridFrom`     | `"md"`, `"lg"`, `"xl"`            | none (grid at every width) | with `"grid"`: the carousel below this width, the grid from it                          |
| `columns`      | `2`, `3`, `4`                     | `3`                        | grid columns at the widest step                                                         |
| `arrows`       | `"top"`, `"bottom"`, `"none"`     | `"top"`                    | carousel arrows                                                                         |
| `loop`         | boolean                           | `false`                    | the carousel wraps at both ends                                                         |
| `moreTile`     | boolean                           | `false`                    | a tile to the group's `href` (else `moreHref`) with its total                           |
| `moreHref`     | string                            | none                       | where an unscoped row's tile links                                                      |
| `moreLabels`   | `Record<string, string>`          | none                       | per group id, the tile's own text (`{count}`, `{label}`) instead of the copy's          |
| `rotate`       | `{ visible, minutes }` or `false` | `false`                    | fair rotation: `visible` tiles per row, a new window every `minutes` (needs the island) |
| `card`         | `RentalCardOptions`               | none                       | the site's `CARD`                                                                       |
| `imageSizes`   | string                            | the card's default         | must follow `--showcase-item-width` or the grid column (below)                          |
| `favoritesKey` | string                            | none                       | binds the hearts once hydrated; absent: the layout's binder paints them                 |
| `shared`       | `SharedCopyOverrides`             | none                       | the cards' `card` and `rental` groups                                                   |

A group's `blurb` renders under the tabs while that group shows. Rotation renders window 0 of the seeded
order on the server and in the hydrating render, then the window of the visitor's clock. `imageSizes`
defaults to the card's `"(min-width: 37rem) 30rem, 82vw"`, true for the default carousel item; a grid or
a changed `--showcase-item-width` passes its own.

**Parts** `SHOWCASE_PARTS`: `showcase` (`data-layout`, `data-grid-from`, `data-columns`, `data-arrows`),
`showcase-tabs`, `showcase-tab` (`aria-pressed`), `showcase-blurb`, `showcase-item`, `showcase-more`. The
row carries headless strip parts (`strip`, `strip-track`, `strip-prev`, `strip-next`), the cards
`RENTAL_CARD_PARTS`.
**Copy** `DEFAULT_SHOWCASE_COPY`: `groupsLabel`, `prev`, `next`, `moreAll` (`{label}`), `moreCount`
(`{count}`, `{label}`: the row's total and label); `moreLabels` replaces either for one group.

```astro
<Showcase client:visible groups={groups} card={CARD} voice={VOICE} layout="grid" gridFrom="lg" moreTile />
```

## SearchResults

The search page's live results. It owns the page's one search store (the satellites and the map attach
by `storeKey`), reads the search from the URL after hydration, and renders the header, the alternatives
notice or section, the grid with its render window and trailing tile, and the outcome states (error,
empty with ways out, favourites and offers empties). Props are read once at hydration: a new store
would be a new rate-limited search. Filters, sort and chips refine in place through the store; the
`SearchBar` navigates.

| Prop           | Values                               | Default                                      | Meaning                                                                                   |
| -------------- | ------------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `sdk`          | `SdkSetup`                           | required                                     | the page shares one instance per setup and mode                                           |
| `contract`     | `SearchContract`                     | required                                     | the site's `SEARCH_CONTRACT`                                                              |
| `rentalHref`   | route template (`{slug}`, `{id}`)    | required                                     | for rentals the facts give no href                                                        |
| `filters`      | `ResolvedFilters`                    | none                                         | `resolveFilters` at build; absent: no filter UI, only period and party reach the SDK      |
| `facts`        | `RentalFactsMap` or `{ url }`        | none                                         | build-time facts by rental id (`rentalFactsMap`), inline or as a JSON file                |
| `requireFacts` | boolean                              | `false`                                      | never list a rental the facts do not know                                                 |
| `scope`        | `SearchScope & { browseHref? }`      | none                                         | the page's narrowing: `ids`, `favorites` or `offers`                                      |
| `favoritesKey` | string                               | none                                         | binds the hearts and the favourites scope (the site's `FAVORITES_KEY`)                    |
| `preset`       | query string                         | none                                         | applied when the URL carries none (a landing page)                                        |
| `sortOptions`  | `SortKey[]`                          | `["recommended", "price-asc", "price-desc"]` | orderings offered; any known URL value is honoured                                        |
| `card`         | `RentalCardOptions`                  | none                                         | the site's `CARD`                                                                         |
| `imageSizes`   | string                               | `"(min-width: 48rem) 50vw, 100vw"`           | `sizes` of the card photos, from the page's geometry                                      |
| `alternatives` | `{ periods?, placement?, notice? }`  | `"chooser"`, `"inline"`, `true`              | how non-exact results of an exact-date search present (below)                             |
| `paging`       | `{ size?, more? }` or `false`        | `{ size: 24, more: "scroll" }`               | the render window; every result is in memory either way                                   |
| `reveal`       | `"complete"`, `"first-page"`         | `"complete"`                                 | first-page shows the first page at once; count and outcome wait                           |
| `header`       | `HeaderOptions` or `false`           | every part on                                | the built-in parts above the list (below)                                                 |
| `empty`        | `{ relaxations?, actions? }`         | `{ relaxations: true }`                      | the empty state's ways out (below)                                                        |
| `applyMode`    | `"in-place"`, `"navigate"`           | `"in-place"`                                 | navigate: filters, chips and ways out load the new URL (pages whose static parts read it) |
| `prerendered`  | boolean                              | `false`                                      | the page server-rendered `PrerenderedResults` beside the island                           |
| `sdkMode`      | `"default"`, `"all-rentals-at-once"` | `"default"`                                  | the mode the page's SDK runs in (at most 100 rentals priced in one call)                  |
| `storeKey`     | string                               | `"search"`                                   | the store's name on the page                                                              |
| `trailingTile` | slot (`ReactNode`)                   | none                                         | [the trailing tile](#the-trailing-tile)                                                   |
| `trailing`     | `TrailingTileOptions`                | `{ showOnEmpty: true }`                      | the tile's partner link and whether it shows on the empty outcome                         |
| `shared`       | `SharedCopyOverrides`                | none                                         | the cards' words                                                                          |

- `alternatives`: `periods` `"chooser"` (a card with several windows opens one dialog listing each with
  its price) or `"first"`; `placement` `"inline"` (in the grid with the card's badge) or `"section"` (a
  divided section after the exact matches); `notice` above the list when every result is an alternative.
  Only an exact start and end search has alternatives: on a window or flexible search they are the answer.
- `paging`: `size` cards per step (24, a multiple of 1 to 4 columns); `more: "scroll"` renders the next
  step a viewport ahead with a visible "load more" fallback, `"button"` the button only; `false` renders
  every card.
- `header`: `count`, `sort`, `filters`, `chips`, each `true` (its defaults), its own props (as
  standalone, without `storeKey`, `locale`, `voice`) or `false`; `filters` and `chips` render only with
  `filters`. A page that places a part elsewhere turns it off here and mounts the satellite itself.
- `empty`: `relaxations` (one link per cause: "Ohne Filter suchen", "Ohne Hund suchen", "Ohne
  Reisedaten suchen", "Für 2 Gäste suchen") and `actions`, the site's own ways out (`ContactLink[]`: an
  enquiry page, a phone, a mail; no default, every value is somebody's real contact).
- `scope.browseHref` is the search the favourites and offers empties link to.

The favourites page is the same component with `scope={{ favorites: true, browseHref }}` and
`favoritesKey`; an un-hearted card leaves with `data-state="leaving"` (no animation under reduced
motion). The offers page passes `scope={{ offers: { window: { startOffsetDays, nights }, campaign? } }}`
(the window numbers are portfolio facts the site measured).

**Parts** `SEARCH_RESULTS_PARTS` (the satellites included): root `search-results` (`data-search-scope`,
`data-state`), `search-results-header`, `search-alternatives-notice`, `search-alternatives-section`,
`search-alternatives-heading`, `search-alternatives-intro`, `search-results-grid` (`data-busy`),
`search-skeleton`, `search-load-more`, `search-results-trailing`, `search-viewport-empty`,
`search-outcome` (`data-search-outcome`, `data-outcome-scope`) with `search-outcome-title`, `-body`,
`-relaxations`, `-link` (`data-kind="relaxation|reset|browse"`), `-actions`, `-action`, `-retry`; the
periods dialog `search-periods-dialog`, `search-periods-intro`, `search-period-row`, `search-period-price`,
`search-period-choose`, `search-periods-other`. Cards carry `RENTAL_CARD_PARTS` and
`data-result-card={id}`.
**Copy** `DEFAULT_SEARCH_RESULTS_COPY` (`SearchResultsCopy`): `emptyTitle`, `emptyFor` (`{parts}` from
`partGuestOne` … `partFilterMany`), `emptyHint`, the relaxations `relaxFilters`, `relaxPets`,
`relaxDates`, `relaxGuests`, `errorTitle`/`errorBody`, `favoritesEmptyTitle`, `favoritesNoneMatch`,
`offersEmptyTitle`, `idsEmpty`, `viewportEmpty`, `loadMore`, `periodsChoose` ("Auswählen": it opens the
rental and books nothing). The header parts take their copy in `header`.
**Events** none (the map reads the store).

```astro
<SearchResults
  client:load
  sdk={SDK}
  contract={SEARCH_CONTRACT}
  rentalHref="/unterkuenfte/{slug}"
  filters={filters}
  facts={{ url: "/rental-facts.json" }}
  card={CARD}
  favoritesKey={FAVORITES_KEY}
  voice={VOICE}
  header={{ count: { as: "h1" }, filters: { panel: "list" } }}
  alternatives={{ placement: "section" }}
  empty={{ actions: [{ label: "Anfrage senden", href: "/anfrage" }] }}
/>
```

### SearchCount

The result count of the page's search, anywhere on the page. It follows the map's viewport, shows a
skeleton until the set is complete, and renders nothing on an error or empty outcome (a "0" beside a
failed search counts nothing). A live region; the suffix follows the store's scope.

| Prop       | Values                          | Default    | Meaning                                                                                     |
| ---------- | ------------------------------- | ---------- | ------------------------------------------------------------------------------------------- |
| `as`       | `"p"`, `"h1"`, `"h2"`, `"span"` | `"p"`      | `"h1"`/`"h2"` when the count is the page heading; `"span"` inside a heading the page writes |
| `storeKey` | string                          | `"search"` | the page's store                                                                            |

**Parts** `search-count`. **Copy** `DEFAULT_SEARCH_COUNT_COPY`: `countOne`, `countMany`
(`countMany: "{count} Ferienwohnungen"` on a landing page), `favoritesSuffix`, `offersSuffix`, `loading`.

### SearchSort

The ordering control: a native select below md, the styled picker from md. It re-sorts the loaded set in
place and keeps `?sort=` for reloads. Hidden while no option but the default can be chosen (an undated
search offers no price order) and on the server.

| Prop          | Values      | Default                                      | Meaning                                                                         |
| ------------- | ----------- | -------------------------------------------- | ------------------------------------------------------------------------------- |
| `sortOptions` | `SortKey[]` | `["recommended", "price-asc", "price-desc"]` | the same list as `SearchResults sortOptions`; a URL sort outside it still shows |

`SortKey`: `recommended`, `price-asc`, `price-desc`, `rating`, `area-desc`, `area-asc`, `guests`,
`bedrooms`, `random` (seeded per page view). The URL values stay German (`empfohlen`, `preis-auf`, …).
**Parts** `search-sort`. **Copy** `DEFAULT_SEARCH_SORT_COPY`: `sortTrigger` (the label while the default
order is on), `sortLabel`, one `sort<Key>` per key.

### SearchFilters

The filter trigger with its count badge and the panel: a dialog from md, a drawer below. The groups and
entries are the page's resolved curation (`SearchResults filters`); the working selection is re-read from
the URL on every open, so closing without "apply" changes nothing. Hidden entries render no control and
stay applied. The badge counts the applied curated filters, hidden ones included, but not an implicit
`?region=` or `?property=` scope: the panel neither offers nor resets it (its chip does). Renders
nothing while the page offers no filter control.

| Prop        | Values                          | Default     | Meaning                                                                  |
| ----------- | ------------------------------- | ----------- | ------------------------------------------------------------------------ |
| `panel`     | `"pills"`, `"list"`             | `"pills"`   | toggle pills and steppers, or check rows and selects ("Beliebig / ab N") |
| `counts`    | boolean                         | `false`     | live counts beside every option the build's facts can count              |
| `whenEmpty` | `"disable"`, `"drop"`, `"show"` | `"disable"` | an option whose live count is 0; a selected option always shows          |
| `compact`   | boolean                         | `false`     | a square icon trigger whose label shows from md (beside a search bar)    |

**Parts** `search-filters-trigger`, `search-filters-badge`, `search-filters-panel`, `search-filters-group`,
`search-filters-group-title`, `search-filters-option` (`data-kind`, `data-selected`),
`search-filters-minimum`, `search-filters-reset`, `search-filters-apply`.
**Copy** `DEFAULT_SEARCH_FILTERS_COPY`: `trigger`, `title`, `reset`, `apply`, `applyCount` (`{count}`),
`any`, `minimum`, `petsOne` (dog-specific), `optionCount`, `optionEmpty`.

### SearchFilterChips

The applied filters as removable chips plus "clear all". Every applied filter is visible and removable,
hidden ones included, and so is an implicit `?region=` or `?property=` scope the curation has no facet
for (the search bar writes `?region=`, an Anlage link `?property=`): headless leads `activeFilters`
with it, and the chip reads "Region: Hafen" or "Anlage: 4711", the URL value verbatim. To chip a
property's name instead of its id, curate a hidden `property` facet whose options carry the names
(`facetOptions(facts, "property", { labels })`); a curated facet replaces the implicit chip with its own
labels. Nothing renders while no filter is applied.

Props: only `storeKey`, `locale`, `voice`, `copy` and `className`.
**Parts** `search-filter-chips`, `search-filter-chip` (`data-kind`), `search-filter-chips-clear`.
**Copy** `DEFAULT_SEARCH_FILTER_CHIPS_COPY`: `facetChip` (`{label}`, `{value}`), `regionChip` and
`propertyChip` (`{value}`: "Region: {value}", "Anlage: {value}"), `minimumChip`, `petsOne`, `petsMany`,
`remove`, `clearAll`, `chipsLabel`.

### SearchQuickFilters

A one-tap filter strip over the page's store. Chips sit in fixed positions (on = filled, off =
outlined) so the strip never moves under the thumb; applied filters without a slot are prepended (an
implicit region or property scope in the chips' words), so no filter is active and invisible. It needs
the page's `filters`. Re-curate `keys` per portfolio: a chip earns a slot only if it splits it.

| Prop            | Values                 | Default    | Meaning                                                                             |
| --------------- | ---------------------- | ---------- | ----------------------------------------------------------------------------------- |
| `keys`          | string[]               | required   | the shortlist in strip order; booleans of the page's resolved filters only          |
| `at`            | `"mobile"`, `"always"` | `"mobile"` | below md only, or at every width (the strip is the only filter UI)                  |
| `prependActive` | boolean                | `true`     | `false` only where a chip row on the same screen already shows every applied filter |
| `pets`          | boolean                | `false`    | offer the dogs chip as the first slot (an active dog search always shows)           |

**Parts** `search-quick-filters`, `search-quick-filter` (`aria-pressed`). **Copy** the
`SearchFilterChipsCopy` table (`copy` takes its keys).

### OfferCampaignFilter

The offers page's one row of campaign chips. A campaign at home in the window on screen is a toggle
(`aria-pressed`) that narrows the loaded results in place; one at home elsewhere is a link that carries its
period in its label, so a change of dates is visible before the click. No counts on chips; labels are
the PMS's, verbatim. Renders nothing while loading or without a campaign; a single chip renders (a lone
campaign at home in another window is the only way to its dates).

| Prop       | Values           | Default  | Meaning                                                                           |
| ---------- | ---------------- | -------- | --------------------------------------------------------------------------------- |
| `windows`  | `OfferWindow[]`  | required | `findOfferWindows` at build; `[]` leaves only the campaigns of the loaded results |
| `action`   | string           | required | the offers page route the links carry                                             |
| `contract` | `SearchContract` | required | the site's `SEARCH_CONTRACT`                                                      |

**Parts** `offer-campaigns`, `offer-campaigns-legend`, `offer-campaign`.
**Copy** `DEFAULT_OFFER_CAMPAIGN_FILTER_COPY`: `legend` ("Angebot:"), `periodTemplate` (`{campaign}`,
`{period}`), `announceSelected`, `announceCleared`, `ariaLabel`.

```astro
<OfferCampaignFilter client:load windows={OFFER_WINDOWS} action="/angebote" contract={SEARCH_CONTRACT} />
<SearchResults client:load … scope={{ offers: { window: { startOffsetDays: 21, nights: 7 } } }} />
```

### PrerenderedResults

The undated listing a crawler must see, server-rendered: the same grid and card markup as the island,
mounted without a client directive (plain HTML, no props serialized). It renders its own
`[data-prerendered-results]` wrapper; `SearchResults prerendered` keeps it while searching, keeps its
order on the bare URL, and removes it in the frame the results paint.

| Prop                                    | Values               | Default  | Meaning                                                                                         |
| --------------------------------------- | -------------------- | -------- | ----------------------------------------------------------------------------------------------- |
| `items`                                 | `SearchResultItem[]` | required | `catalogueResultItem(…)` over the catalogue, in `compareUndatedSearchOrder` or the page's order |
| `card`, `facts`, `imageSizes`, `shared` | as on the island     | none     | pass the same values as the island, or the swap moves cards                                     |
| `trailingTile`                          | slot                 | none     | the same tile as the island's; its links render without search values                           |
| `copy`                                  | `SearchResultsCopy`  | none     | accepted so a page passes one object to both; the listing renders no words of its own           |

```astro
<PrerenderedResults items={items} card={CARD} facts={facts} />
<SearchResults client:load … facts={facts} card={CARD} prerendered />
```

The page also inlines headless `resultsQueryFlagScript()` in `<head>`, so a URL with a query hides the
listing before paint.

### The trailing tile

A site tile after the last result card (a partner portal, an enquiry tile), passed as the
`trailingTile` slot of `SearchResults` (and `PrerenderedResults`). It is not a result: not counted, no
`data-result-card`, so the count, the map and the gates never mistake it for a rental. `trailing`
(`TrailingTileOptions`) sets `link` (a headless `ExternalSearchLink`: every `[data-trailing-href]` anchor
in the tile gets the partner search for the guest's current dates and party) and `showOnEmpty` (also
centred under the empty outcome, default `true`). Part: `search-results-trailing`.

```astro
<SearchResults client:load … trailing={{ link: PARTNER_LINK }}>
  <a slot="trailingTile" data-trailing-href href="https://partner.example">Mehr Unterkünfte beim Partner</a>
</SearchResults>
```

## RentalCard

The one rental card of every list: `Showcase`, `SearchResults`, `PrerenderedResults` and the map popup.
It renders headless `toCardModel(source, options)` and owns no rule. Every difference between sites is a
key of the site's one `RentalCardOptions` object (`CARD`), passed to every surface so all cards agree.
Server-renderable; it keeps the kit hooks (`.card-images`, `data-img-*`, `data-dots`, `data-fav`,
`data-fav-id`, `data-result-card` on result cards) that site scripts and gates read. Photos 2–n wait in
`data-src` until the visitor reaches for the strip; hearts are painted by the favourites binder.

| Prop                        | Values                   | Default                            | Meaning                                                                         |
| --------------------------- | ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------- |
| `source`                    | `CardSource`             | required                           | `cardSource(rental, { href })` at build, or a live result merged with its facts |
| `options`                   | `RentalCardOptions`      | none                               | the site's `CARD`                                                               |
| `variant`                   | `"showcase"`, `"result"` | `"showcase"`                       | a result card adds the status chip, alternative windows and the price           |
| `exactSearch`               | boolean                  | `false`                            | gates the "alternative period" chip                                             |
| `imageSizes`                | string                   | `"(min-width: 37rem) 30rem, 82vw"` | `sizes` of the photos                                                           |
| `locale`, `voice`, `shared` |                          | the surrounding surface's          | standalone use only; any of them makes the card resolve its own copy            |

`RentalCardOptions` (every key optional; the first block is the headless card model's):

| Key             | Values                                                          | Default                                      | Meaning                                                                     |
| --------------- | --------------------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------- |
| `facts`         | `"guests"`, `"bedrooms"`, `"bathrooms"`, `"area"`, `"distance"` | `["guests", "bedrooms", "area", "distance"]` | which facts, in order; each only when stated (never a 0)                    |
| `distance`      | `{ prefer?: DistanceKind[], as?: "fact" \| "pill" }`            | water kinds by priority, as a fact           | which distance and where                                                    |
| `pills`         | `PillOptions` or `false`                                        | `{ limit: 3, fill: true }`                   | owner pills, then curated feed highlights (below)                           |
| `badges`        | `{ rules: BadgeRule[], limit? }`                                | none (`limit` 2)                             | photo claims from PMS signals, never from a distance threshold              |
| `maxImages`     | number                                                          | `5`                                          | photos kept; `1` is a still                                                 |
| `priceCents`    | `"hide-zero"`, `"show"`, `"truncate"`, `"round"`                | `"hide-zero"`                                | applies to total, original and every period price                           |
| `offer`         | `"footer"`, `"pills"`, `"badge"`                                | `"footer"`                                   | where the PMS offer labels show                                             |
| `factStyle`     | `"inline"`, `"columns"`, `"pills"`                              | `"inline"`                                   | one row, a two-column grid, or the facts open the pill row                  |
| `factGlyphs`    | `"none"`, `"default"`, `"mapped"`                               | `"none"`                                     | a glyph before each fact (inline facts without glyphs are middot-separated) |
| `factGlyphMap`  | `Partial<Record<FactKind, Glyph>>`                              | none                                         | per-fact overrides (an entry wins in `"default"` too)                       |
| `rating`        | `"badge"`, `"star"`, `"none"`                                   | `"badge"`                                    | score and count, "★ 4,9 (12)", or a ruling against rating display           |
| `ratingAt`      | `"footer"`, `"title"`                                           | `"footer"`                                   | where the rating sits                                                       |
| `favorite`      | `"inline"`, `"photo"`, `"none"`                                 | `"inline"`                                   | the heart in the location row, on the photo, or none                        |
| `surface`       | `"raised"`, `"outlined"`, `"plain"`                             | `"raised"`                                   | CSS reads it from `data-surface`                                            |
| `imageLoop`     | boolean                                                         | `false`                                      | the photo strip wraps                                                       |
| `headingLevel`  | `2`–`6`, `"none"`                                               | `"none"`                                     | a card name is a label in a list, not a section of the outline              |
| `cta`           | `"arrow"`, `"text"`, `"pick-dates"`, `"none"`                   | showcase `"arrow"`, result `"none"`          | the footer affordance when no price shows                                   |
| `pickDatesHref` | string                                                          | `"#search"`                                  | where `"pick-dates"` links                                                  |
| `price`         | `"total"`, `"hidden"`                                           | `"total"`                                    | the stay total, or no price (a ruling); never a per-night price             |
| `originalPrice` | `"above"`, `"inline"`                                           | `"above"`                                    | the struck original above the total or before it (`data-original`)          |

**Parts** `RENTAL_CARD_PARTS`: `rental-card` (`data-variant`, `data-surface`, `data-status`,
`data-state="leaving"`), `rental-card-media`, `rental-card-image`, `rental-card-prev`, `rental-card-next`,
`rental-card-dots`, `rental-card-status` (`data-kind`), `rental-card-badges`, `rental-card-badge`,
`rental-card-favorite` (`data-position`), `rental-card-location`, `rental-card-title`, `rental-card-link`,
`rental-card-blurb`, `rental-card-facts`, `rental-card-fact` (`data-kind`, `data-style`),
`rental-card-pills`, `rental-card-pill`, `rental-card-period`, `rental-card-periods`,
`rental-card-footer`, `rental-card-rating`, `rental-card-reviews`, `rental-card-cta`, `rental-card-price`,
`rental-card-price-original`, `rental-card-price-total`, `rental-card-nights`, `rental-card-stay`,
`rental-card-offers`. Each is present only when its data is.
**Copy** the shared `card` group (`CardCopy`): fact templates (`guestOne` … `area`), `distance`
(`{label} {distance}`), `statusOffer`, `statusAlternative`, `freeInPeriod`, `periodsMore`, `cta`,
`selectDates`, `save`, `imageAlt` (`{name}`, `{n}`); distance labels are the `rental` group.

```ts
export const CARD: RentalCardOptions = {
  facts: ["guests", "bedrooms", "area"],
  pills: { limit: 2, exclude: ["WLAN"] },
  badges: { rules: [{ when: { flag: "petsAllowed" }, label: "Hunde willkommen", icon: "paw" }] },
  rating: "star",
  surface: "outlined",
};
```

`pills` (`PillOptions`): `limit` (default 3; 0 = no row), `fill` (fill from feed highlights after the
owner pills, default `true`), `only` (an exclusive allowlist of feed highlights), `prefer`, `exclude`,
`rename` (display names) and `glyphMap` (a glyph per feed label; text only without one). Owner pills
always lead, outside `only` and `exclude`. The card's name is always its one stretched link.
A badge rule's `when` is a headless `CardMatch` (`{ flag }`, `{ stated }`, `{ rating }`, `{ region }`,
`anyOf`, `allOf`, …), its `icon` a headless `Glyph`; one badge per `group`, the first rule that matches.

## Rental page blocks

Eleven server-rendered blocks for the rental detail page, each self-hiding without data and inventing
nothing (no fact prints a 0, no "Ferienwohnung" the feed did not state). Their full text is in the HTML
(hidden, not absent) for search engines and the fact-check gate. The layout's `enhance()` binds their
dialogs, reveals and strips; a block mounted as an island binds its own subtree as well. Render them
without a client directive.

Every block but `RatingBadge` (which takes a review summary) takes `RentalBlockProps`: `rental`
(required: the `RentalDetail` from `loadRentalCatalogue` or `adaptRental`), `locale`, `voice`, `shared`
(the `rental` and `common` groups) and `className`; each adds its own `copy`. A page passes one object:

```astro
---
const block = { rental, locale, voice: VOICE };
---
<RentalHeading {...block} address={ADDRESS} />
<RentalActions {...block} />
<RentalGallery {...block} />
<RentalFactList {...block} />
<RentalDescription {...block} />
<RentalAmenities {...block} />
<RentalRooms {...block} />
<RentalReviews {...block} />
<RentalProperty {...block} />
<RentalDistances {...block} />
```

All block parts are in `RENTAL_DETAIL_PARTS` (an object keyed by name, e.g. `RENTAL_DETAIL_PARTS.title`
is `"rental-title"`); the engines' parts (`layer`, `reveal`, `clamp`, `share`, `favorite`, the gallery's
dialogs and openers) are headless `ENHANCE_PARTS`. The blocks' dialogs share `rental-layer-header` and
`rental-layer-title`; a dialog's id is the URL hash while it is open.

### RentalHeading

The page's name, the line above it and the address line under it.

| Prop           | Values                                                   | Default           | Meaning                                                                       |
| -------------- | -------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------- |
| `address`      | `"street"`, `"town"`, `"none"`                           | required          | the site's `ADDRESS` (or `"none"` for no line); throws at render when missing |
| `eyebrow`      | `"type-and-town"`, `"town"`, `"town-with-pin"`, `"none"` | `"type-and-town"` | the line above the name; the town alone when the feed states no type          |
| `eyebrowText`  | string                                                   | none              | a site-composed eyebrow; overrides `eyebrow`                                  |
| `headingLevel` | `1`, `2`                                                 | `1`               | `2` where the page already has its h1                                         |

`"street"` without a stated street prints the town (less than was decided, never more). A missing or
unknown `address` throws headless `RentalAddressModeError`, so the build fails instead of publishing a
decision nobody made. **Parts** `rental-heading`, `rental-eyebrow`, `rental-title`, `rental-address`.
**Copy** `DEFAULT_RENTAL_HEADING_COPY`: `typeAndTown` ("{type} in {town}"), `townOnly` ("{town}";
`copy={{ townOnly: "Ferienwohnung in {town}" }}` brings the 0.1.x wording back).

### RentalActions

The utility row: the guest rating (to the reviews), a map link, save (Merkliste) and share. Never render
it at a width where the gallery's `overlayActions` shows the same buttons.

| Prop           | Values                                       | Default                           | Meaning                                                                  |
| -------------- | -------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------ |
| `items`        | `"rating"`, `"map"`, `"favorite"`, `"share"` | `["rating", "favorite", "share"]` | which actions, in order; `"rating"` self-hides without reviews           |
| `layout`       | `"bar"`, `"pills"`, `"icons"`                | `"bar"`                           | rating left and actions right; labelled pills; icon buttons over a photo |
| `shareOrigin`  | string                                       | the page's origin                 | canonical origin of the shared link                                      |
| `ratingHref`   | string                                       | `"#reviews"`                      | where the rating links                                                   |
| `mapHref`      | string                                       | `"#map"`                          | where the map link points                                                |
| `favoritesKey` | string                                       | none                              | for an island; absent: the layout's binder paints the heart              |

**Parts** `rental-actions`, `rental-action` (the map link), `rental-action-label`; the heart and share are
`favorite` and `share` (`ENHANCE_PARTS`). **Copy** `DEFAULT_RENTAL_ACTIONS_COPY`: `save` ("Merken"),
`saved`, `saveLabel`, `removeLabel`, `share`, `shared` ("Link kopiert"), `map`. The gallery's overlay
buttons read the same table.

### RentalGallery

The photos: a swipe strip below `mosaicFrom`, the cover beside a mosaic from it, the all-photos grid, the
fullscreen viewer and the floor plans. All markup is server-rendered on headless `bindGallery`'s
contract: the cover is the LCP element and is fetched once; grid, viewer and floor-plan photos are lazy.
One gallery per page (its dialog ids `gallery`, `gallery-viewer`, `floorplan` are URL hashes).

| Prop                          | Values                                        | Default              | Meaning                                                                    |
| ----------------------------- | --------------------------------------------- | -------------------- | -------------------------------------------------------------------------- |
| `mosaic`                      | `"cover+4"`, `"cover+2"`                      | `"cover+4"`          | the cover beside a 2×2 of four photos, or beside two stacked ones          |
| `mosaicFrom`                  | `"md"`, `"lg"`, `"xl"`                        | `"xl"`               | from this width the mosaic replaces the strip (a tablet swipes)            |
| `browse`                      | `"grid"`, `"viewer"`                          | `"grid"`             | what a mosaic tile opens: the all-photos grid, or the viewer straight away |
| `viewerTone`                  | `"dark"`, `"light"`                           | `"dark"`             | the surface behind the viewer                                              |
| `floorplans`                  | `"separate"`, `"viewer-tail"`                 | `"separate"`         | their own button and dialog, or also at the end of the viewer              |
| `overlayActions`              | `"none"`, `"always"`, `{ below: Breakpoint }` | `"none"`             | save and share over the photos where no `RentalActions` shows them         |
| `shareOrigin`, `favoritesKey` | as on `RentalActions`                         | none                 | for the overlay buttons                                                    |
| `viewerBox`, `floorplanBox`   | `{ width, height }`                           | 1440 × 1080          | pre-load boxes so a lazy image keeps its frame                             |
| `sizes`                       | `Partial<Record<GallerySurface, string>>`     | the default geometry | per surface: `strip`, `cover`, `tile`, `grid`, `viewer`, `floorplan`       |

**Parts** `rental-gallery-mosaic`, `rental-gallery-photo`, `rental-gallery-slide`,
`rental-gallery-actions`, plus the engine's `rental-gallery`, `rental-gallery-tile`, `rental-gallery-all`,
`rental-gallery-grid`, `rental-gallery-viewer`, `rental-gallery-caption`, `rental-gallery-counter`,
`rental-floorplan-open`, `rental-floorplan`. **Copy** `DEFAULT_RENTAL_GALLERY_COPY`: `showAll`
("Alle Fotos ({count})"), `openGallery`, `openPhoto`, `gridTitle`, `floorplanOne`, `floorplanMany`,
`imageAlt` ("{caption} – {name}"), `imageAltFallback`, `stripLabel`, `counter`.

```astro
<RentalGallery {...block} overlayActions={{ below: "md" }} mosaicFrom="lg" />
```

### RentalFactList

The key facts: guests, bedrooms, bathrooms, area, the water distance, dogs, the type, each only when the
feed states it. Carries the gate marker `data-rental-stats`.

| Prop            | Values                              | Default                                                           | Meaning                                                                |
| --------------- | ----------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `facts`         | `FactKey[]`, `"extra"`              | `["guests", "bedrooms", "area", "bathrooms", "distance", "pets"]` | which facts, in order; `"extra"` is where `extraFacts` go              |
| `layout`        | `"pills"`, `"counters"`, `"inline"` | `"pills"`                                                         | pills; glyph and figure between hairlines; one middot line (no glyphs) |
| `glyphs`        | `"none"`, `"default"`, `"mapped"`   | `"default"`                                                       | with `glyphMap` for `"mapped"`                                         |
| `distanceKinds` | `DistanceKind[]`                    | water kinds (`beach`, `sea`, `lake`)                              | kinds the distance fact may state, by priority; `[]` = none            |
| `extraFacts`    | `(string \| ExtraFact)[]`           | none                                                              | site-derived facts (`{ label, glyph? }`); after the listed facts       |
| `rating`        | `RatingPart[]` or `"none"`          | `"none"`                                                          | a rating chip at the end, e.g. `["score", "count"]`                    |
| `ratingHref`    | string                              | `"#reviews"`                                                      | where the chip links                                                   |

`FactKey`: `type` (the feed's rental type, verbatim), `guests`, `bedrooms`, `bathrooms`, `area`,
`distance`, `pets` (the pets flag in dog-specific words). `extraFacts` are an owner's custom
attributes, verbatim: a string draws a check, an `ExtraFact` its own `glyph` (a headless `Glyph`, e.g.
`{ label: "Sandstrand", glyph: "pin" }`). They follow the listed facts unless `facts` names the slot:
`facts={["guests", "bedrooms", "area", "bathrooms", "distance", "extra", "pets"]}`.
**Parts** `rental-facts`, `rental-fact` (`data-kind`: the `FactKey`, `extra`, or `rating` for the
chip). **Copy** `DEFAULT_RENTAL_FACT_LIST_COPY`:
`guestOne` … `area`, `listLabel` ("Eckdaten"); German bathrooms stay "Badezimmer" in both numbers (the
fact-check gate matches it). Distance and dog wording are the shared `rental` group.

### RatingBadge

The guest rating as a chip: in the fact list, the action bar, or a site's own hero. It takes a
`ReviewSummary`, not the rental, so there is no badge without one; the grade is the PMS's own, never
derived from the score.

| Prop      | Values                          | Default              | Meaning                                      |
| --------- | ------------------------------- | -------------------- | -------------------------------------------- |
| `summary` | `ReviewSummary`                 | required             | `rental.reviews.summary`                     |
| `show`    | `"score"`, `"grade"`, `"count"` | `["score", "count"]` | what it states, in order                     |
| `href`    | string                          | none                 | a link (the reviews section); absent: a chip |

**Parts** `rating-badge`, `rating-badge-score`, `rating-badge-grade`, `rating-badge-count`.
**Copy** `DEFAULT_RATING_BADGE_COPY`: `score` ("{score}"; a site may write "{score}/5"), `label`.

```astro
{rental.reviews.summary && <RatingBadge summary={rental.reviews.summary} href="#reviews" />}
```

### RentalDescription

The PMS description with a preview cut at a sentence end (headless `descriptionPreview`, decided at
build, so no line clamp cuts mid-sentence). The full text is in the HTML between the gate markers
`data-rental-description` and `data-rental-description-end`.

| Prop             | Values                 | Default               | Meaning                                                       |
| ---------------- | ---------------------- | --------------------- | ------------------------------------------------------------- |
| `preview`        | `PreviewStrategy`      | `{ by: "sentences" }` | where the visible part ends (below)                           |
| `more`           | `"dialog"`, `"inline"` | `"dialog"`            | the rest in a dialog that repeats the preview, or in place    |
| `emphasiseFirst` | boolean                | `false`               | the first paragraph as the lead (`data-emphasise-first`)      |
| `intro`          | boolean                | `false`               | the PMS short description above the text                      |
| `headline`       | boolean                | `false`               | the PMS headline as the heading text (needs a `headingLevel`) |
| `headingLevel`   | `1`–`6`, `"none"`      | `"none"`              | the description introduces itself                             |
| `id`             | string                 | `"description"`       | the section's id; the dialog is `<id>-full`                   |

`preview` (`PreviewStrategy`): `{ by: "sentences", budget?, min? }` (whole blocks while they fit
`budget` characters, default 320, never under `min`, 160), `{ by: "paragraphs", count }` or
`{ by: "none" }` (everything behind the control). Lists are never cut.
**Parts** `rental-description`, `rental-description-heading`, `rental-description-intro`,
`rental-description-preview`, `rental-description-full`, `rental-description-more`.
**Copy** `DEFAULT_RENTAL_DESCRIPTION_COPY`: `heading`, `more` ("Mehr lesen"), `less`, `dialogLabel`.

### RentalAmenities

A short preview of what the rental offers and "show all" opening the whole catalogue by PMS category in a
dialog. The whole catalogue is in the HTML (gate markers `data-rental-amenities`, `data-amenities-modal`,
`data-amenities-overlay-end`).

| Prop           | Values                                            | Default                            | Meaning                                                                   |
| -------------- | ------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------- |
| `preview`      | `{ from?: "highlights" \| "categories", count? }` | `{ from: "highlights", count: 8 }` | preview rows above the button; `count: 0` = the button only               |
| `lead`         | `"distance"`, `"pets"`                            | `[]`                               | facts leading the preview when stated                                     |
| `glyphs`       | `"none"`, `"default"`, `"mapped"`                 | `"default"` (a check)              | `"mapped"`: `AMENITY_GLYPHS` + `glyphMap` by PMS label, a check otherwise |
| `groupGlyphs`  | `"none"`, `"mapped"`                              | `"none"`                           | a glyph on each category heading in the dialog                            |
| `glyphMap`     | `Record<string, Glyph>`                           | none                               | extra or replacing entries of the label map                               |
| `headingLevel` | `1`–`6`, `"none"`                                 | `2`                                |                                                                           |
| `id`           | string                                            | `"amenities"`                      | the dialog is `<id>-all`                                                  |

**Parts** `rental-amenities`, `rental-amenities-heading`, `rental-amenity-list`, `rental-amenity`
(`data-kind`: `highlight` for a PMS amenity, `distance` or `pets` for a leading fact),
`rental-amenities-all`, `rental-amenity-group`, `rental-amenity-group-heading`.
**Copy** `DEFAULT_RENTAL_AMENITIES_COPY`: `heading` ("Ausstattung"), `showAll` (`{count}`), `dialogTitle`.

```astro
<RentalAmenities {...block} preview={{ count: 12 }} lead={["distance", "pets"]} glyphs="mapped"
  copy={{ heading: "Das bietet die Unterkunft" }} />
```

### RentalRooms

Every room the feed states, in SDK order: the name (an ordinal only where a type repeats), the beds as
runs ("2× Einzelbett"), then what the room carries. A bathroom with nothing else stated keeps its name.

| Prop             | Values                            | Default                        | Meaning                                                          |
| ---------------- | --------------------------------- | ------------------------------ | ---------------------------------------------------------------- |
| `rooms`          | `"all"`, `"with-beds"`            | `"all"`                        |                                                                  |
| `beds`           | `"line"`, `"list"`                | `"line"`                       | one line per room, or a list with each bed's attributes          |
| `hideBedTypes`   | string[]                          | `[]`                           | bed types not printed beside the kind                            |
| `glyphs`         | `"none"`, `"default"`, `"mapped"` | `"default"`                    | bed, bath, sofa, kitchen, else a house; `glyphMap` by `RoomKind` |
| `extras`         | string[]                          | none                           | further sleeping places the site derived                         |
| `attributeShort` | `Record<string, string>`          | `DEFAULT_ROOM_ATTRIBUTE_SHORT` | shortenings (never renames); a site's map replaces it            |
| `headingLevel`   | `1`–`6`, `"none"`                 | `2`                            | room names render one level below                                |
| `id`             | string                            | `"rooms"`                      |                                                                  |

Each attribute carries the gate marker `data-room-attr`. **Parts** `rental-rooms`, `rental-rooms-heading`,
`rental-room-list`, `rental-room`, `rental-room-name`, `rental-room-description`, `rental-room-beds`,
`rental-room-bed`, `rental-room-attributes`, `rental-room-extras`, `rental-room-extras-heading`.
**Copy** `DEFAULT_RENTAL_ROOMS_COPY`: `heading` ("Zimmer & Räume"), `ordinal`, `bed`, `bedCount`,
`extrasHeading`.

### RentalReviews

The rating summary (score, the PMS's grade, the count, per-criterion bars) and the reviews, newest first.
No summary, no section; each review is in the HTML once whatever the layout.

| Prop             | Values                               | Default                   | Meaning                                                               |
| ---------------- | ------------------------------------ | ------------------------- | --------------------------------------------------------------------- |
| `initial`        | number                               | `1`                       | reviews shown before "more"                                           |
| `step`           | number                               | all that remain           | reviews each "more" reveals                                           |
| `layout`         | `"adaptive"`, `"carousel"`, `"list"` | `"adaptive"`              | adaptive: a swipe row below md, a list with "more" from md            |
| `texts`          | boolean                              | `true`                    | `false` shows the summary only (feeds whose texts are survey answers) |
| `summary`        | `"grade"`, `"criteria"`              | both                      | the summary's parts beside score and count                            |
| `clampLines`     | number                               | `10`                      | lines before a long review's toggle (only on overflow)                |
| `criterionShort` | `Record<string, string>`             | `DEFAULT_CRITERION_SHORT` | criterion shortenings; a site's map replaces it                       |
| `headingLevel`   | `1`–`6`, `"none"`                    | `2`                       |                                                                       |
| `id`             | string                               | `"reviews"`               | the rating links point here                                           |

**Parts** `rental-reviews`, `rental-reviews-heading`, `rental-review-summary`, `rental-review-score`,
`rental-review-grade`, `rental-review-count`, `rental-review-criteria`, `rental-review-criterion`,
`rental-review-criterion-label`, `rental-review-criterion-value`, `rental-review-bar`,
`rental-review-list`, `rental-review`, `rental-review-title`, `rental-review-author`,
`rental-review-read-more`, `rental-review-read-less`, `rental-reviews-more`.
**Copy** `DEFAULT_RENTAL_REVIEWS_COPY`: `heading` (`{reviews}`, `{score}`, `{scale}`), `from`, `more`,
`readMore`, `readLess`; the count and "Gast" are the shared `rental` group.

### RentalProperty

The property (Anlage) the rental belongs to: its name, text and highlights as the feed states them.
Renders nothing when the rental belongs to no property or the property states no text.

| Prop           | Values                  | Default      | Meaning                                             |
| -------------- | ----------------------- | ------------ | --------------------------------------------------- |
| `layout`       | `"open"`, `"collapsed"` | `"open"`     | collapsed behind its heading (a native `<details>`) |
| `href`         | string                  | none         | the property's own page                             |
| `headingLevel` | `1`–`6`, `"none"`       | `2`          |                                                     |
| `id`           | string                  | `"property"` |                                                     |

**Parts** `rental-property`, `rental-property-heading`, `rental-property-name`,
`rental-property-highlights`, `rental-property-link`. **Copy** `DEFAULT_RENTAL_PROPERTY_COPY`: `heading`
("Über die Anlage"), `link`.

### RentalDistances

The "Entfernungen" list: label, then metres or kilometres; below the adjacency threshold the statement
("Direkt am Strand", "Zentrale Lage") stands alone. Whether it sits above or below the map is page layout.

| Prop           | Values                            | Default                                    | Meaning                                                           |
| -------------- | --------------------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| `kinds`        | `DistanceKind[]`                  | every stated kind, in `DistanceKind` order | order and allowlist (a mountain site lists `city`, `lake`, `ski`) |
| `layout`       | `"list"`, `"inline"`              | `"list"`                                   | a `<dl>`, or one middot row                                       |
| `glyphs`       | `"none"`, `"default"`, `"mapped"` | `"none"`                                   | `glyphMap` by `DistanceKind` (defaults below)                     |
| `headingLevel` | `1`–`6`, `"none"`                 | `3`                                        |                                                                   |

Default glyphs (here, in the fact list, the amenity preview and the card): waves for `beach` and `sea`,
a sailboat for `lake` (a row stating both tells them apart), a building for `city`, a mountain for
`ski`, a pin for every other place. **Parts** `rental-distances`, `rental-distances-heading`,
`rental-distance`, `rental-distance-label`, `rental-distance-value`. **Copy** `DEFAULT_RENTAL_DISTANCES_COPY`: `heading` ("Entfernungen"); labels
are the shared `rental` group (`distanceBeach` …, `adjacentBeach` …), relabelled through
`shared={{ rental: { distanceCity: "Ortsmitte" } }}`.

## Quote

The rental page's booking widget: dates, guests, live price, rate choice and the CTA. It prices a
complete stay automatically and never books: the CTA hands the priced stay to the checkout. One island
per placement (`"card"` in a sidebar, `"bar"` fixed at the bottom), all sharing the rental's one flow
(one availability request, one quote per stay).

The package positions the bar: fixed to the viewport's bottom edge at full width, one top border on
`--background`, the safe-area insets kept, above the page and below the package's dialogs and sheets
(`z-index` 40). It hides itself from `barBelow` (default `lg`), where the page shows the card. A site
wraps it in nothing (a wrapper's border or surface doubles the bar's). `barLayout="floating"` keeps
that bar below md and, from md, floats it as a centred card: at most 40rem wide, 1rem off the bottom
edge, a border all round, `--radius-card` and `--shadow-card`; a site restyles either on
`[data-part="quote"][data-variant="bar"]` (`[data-layout="floating"]`). The bar covers the page's
last lines, so the page keeps room below its footer below that width; the closed bar grows by its
total row once priced, which `:root:has([data-bookbar-total])` can read.

| Prop               | Values                                             | Default           | Meaning                                                                                      |
| ------------------ | -------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| `sdk`              | `SdkSetup`                                         | required          | one instance per page is shared by every island                                              |
| `rental`           | `QuoteRental` (`id`, `maxGuests?`, `petsAllowed?`) | required          | id, catalogue capacity, pets flag (below)                                                    |
| `checkoutHref`     | string or `null`                                   | required          | this rental's checkout route; `null` is a deliberate enquiry-only page (no CTA link)         |
| `variant`          | `"card"`, `"bar"`                                  | `"card"`          | the bar is fixed at the bottom, its pickers open as bottom sheets, closed it shows the total |
| `barBelow`         | `"md"`, `"lg"`, `"xl"`                             | `"lg"`            | the bar shows below this width and hides from it, where the card shows                       |
| `barLayout`        | `"full"`, `"floating"`                             | `"full"`          | the bar at full width, or (`"floating"`) a centred card from md up to `barBelow`             |
| `guests`           | `GuestOptions`                                     | headless defaults | the site's `GUESTS`                                                                          |
| `dayPrices`        | `"rounded"`, `"exact"`, `"none"`                   | `"rounded"`       | nightly prices in the calendar cells; the site's `DAY_PRICES`                                |
| `breakdown`        | `"folded"`, `"open"`, `"none"`                     | `"folded"`        | the lines behind a native `<details>`, listed, or the total only                             |
| `discountDisplay`  | `"rows"`, `"struck-total"`                         | `"rows"`          | PMS modifiers as deduction rows, or the PMS's pre-modifier total struck; inert on v9         |
| `policyChoice`     | `"rental-page"`, `"checkout"`                      | `"rental-page"`   | where the rate is chosen; must equal `Booking`'s                                             |
| `campaignLabels`   | `"none"`, `"from-search"`                          | `"none"`          | show the offer label the search measured for this stay (a label, never a struck price)       |
| `whenUnavailable`  | `"none"`, `"next-arrival"`                         | `"none"`          | offer the first arrivable day after a refused start                                          |
| `secondaryAction`  | `{ kind: "option" }`, `{ kind: "enquiry", href }`  | none              | a link under the CTA (below)                                                                 |
| `minStayNights`    | number                                             | none              | the site's minimum stay on top of the PMS; a hint under the calendar                         |
| `bookable`         | `BookableFrom`                                     | none              | the not-bookable-yet notice (as SearchBar's) and the opening month; does not floor           |
| `contact`          | `ContactLink`                                      | none              | under unavailable, rate-fault and error outcomes, never on a priced stay                     |
| `cancellationNote` | string                                             | none              | one line under the CTA once priced and only when the quote carries no policy (v9)            |
| `policyCopy`       | `Partial<PolicyCopy>`                              | none              | the rate rows' strings (`DEFAULT_POLICY_COPY`); pass `Booking` the same object               |
| `shared`           | `SharedCopyOverrides`                              | none              | the pickers' `dates`, `guests`, `common`                                                     |

`rental` (`QuoteRental`): `maxGuests` caps the steppers where availability states none (v9 before
SDK 2.28; from 2.28 v9 availability also rules out children and caps dogs);
`petsAllowed: false` means no dog can be added (one handed over from the search can be lowered, and is
still sent); leave a key out when it is not known. `secondaryAction`: `{ kind: "option" }` links the
checkout with `kind=option`, a non-binding reservation (`Booking` must set `allowOption`), only on a
priced stay; `{ kind: "enquiry", href }` links the site's enquiry page with the stay in the query.

**Parts** `QUOTE_PARTS`: `quote` (`data-variant`, `data-outcome`; the bar adds `data-below` and
`data-layout` and keeps `data-mobile-bookbar`),
`quote-field` (`data-field`, `data-filled`, `data-loading`), `quote-field-icon`, `quote-dates-panel`,
`quote-guests-panel`, `quote-panel-action` (`data-kind`), `quote-calendar` (`data-phase`), `quote-day`
(`data-day`, `data-selected`, `data-preview`), `quote-day-price`, `quote-day-tooltip`,
`quote-nights-badge`, `quote-calendar-footer`, `quote-min-stay`, `quote-bookable-from`,
`quote-policy-choice`, `quote-policy-option` (`data-selected`, `data-available`), `quote-policy-terms`,
`quote-deadline-note`, `quote-price` (`data-breakdown`, `data-display`), `quote-offer-chip`,
`quote-total`, `quote-struck-total`, `quote-line` (`data-line`), `quote-status` (`data-outcome`),
`quote-contact`, `quote-next-arrival`, `quote-cta` (`data-cta`), `quote-secondary` (`data-kind`),
`quote-cancellation-note`, `quote-bar-toggle`, `quote-bar-total` (keeps `data-bookbar-total`).
**Copy** `DEFAULT_QUOTE_COPY` (`QuoteCopy`): `checkAvailability`, `continueToBooking`, `completeAges`,
`changeDates`, `unavailable`, `rateFault` (the days are free but the PMS rate setup cannot be resolved:
never "not available"), `quoteLoadFailed`, `calendarLoadFailed`, `minStayHint`, `bookableFromNotice`
("Online buchbar für Aufenthalte ab {date}.", `{date}` at `bookable.precision`),
`nextArrival`, `takeNextArrival`, `optionAction`, `enquiryAction`, `totalLabel`. `DEFAULT_POLICY_COPY`
(`PolicyCopy`, shared with `Booking`): `title`, `freeCancellationLine` (`{date}` emphasised), `ruleLine`,
`refundableLabel`, `expiredLabel`, `standardLabel`.
**Events** `voffice:quote` `{ type: "rate-fault" }` once per stay; a console line is always written.

```astro
---
// Absent when not known: an unknown pets flag is no `false`.
const quoteRental: QuoteRental = {
  id: rental.id,
  ...(rental.counts.guests === undefined ? {} : { maxGuests: rental.counts.guests }),
  ...(rental.flags.petsAllowed === undefined ? {} : { petsAllowed: rental.flags.petsAllowed }),
};
const quote = {
  sdk: SDK,
  voice: VOICE,
  rental: quoteRental,
  checkoutHref: `/buchen/${rental.slug}`,
  guests: GUESTS,
  dayPrices: DAY_PRICES,
  policyChoice: POLICY_CHOICE,
};
---
<aside class="hidden lg:block"><Quote client:visible {...quote} /></aside>
<!-- No wrapper: the bar fixes and hides itself (barBelow "lg"); client:media names the same width. -->
<Quote client:media="(max-width: 63.98rem)" {...quote} variant="bar" />
```

### AvailabilityCalendar

A read-only occupancy calendar for the page body. It joins the rental's quote flow (no second
availability request) and always shows the arrival view (booked · arrival possible · no arrival). An
arrival click hands only the arrival to the widget, which owns the departure rules, and brings the
visible widget into view with its calendar open. On a failed availability map it renders no grid (an
empty grid reads as fully booked).

| Prop        | Values                           | Default   | Meaning                                                                       |
| ----------- | -------------------------------- | --------- | ----------------------------------------------------------------------------- |
| `sdk`       | `SdkSetup`                       | required  | the same setup as `Quote`'s                                                   |
| `rental`    | `QuoteRental`                    | required  | the same object as `Quote`'s                                                  |
| `months`    | `1`, `2`                         | `2`       | side by side on wide screens; one below md                                    |
| `dayPrices` | `"rounded"`, `"exact"`, `"none"` | `"none"`  | an occupancy calendar shows no prices unless the site passes its `DAY_PRICES` |
| `onArrival` | `"quote"`, `"none"`              | `"quote"` | an arrival click seeds the widget, or display only (no buttons)               |

**Parts** `availability-calendar` (`data-state`), the months as `quote-calendar` (`data-phase="arrival"`),
`availability-legend` (items carry `data-kind`). **Copy** `DEFAULT_AVAILABILITY_CALENDAR_COPY`:
`legendBooked`, `legendArrival`, `legendNoArrival`, `note` (empty by default and then not rendered).

```astro
<AvailabilityCalendar client:visible sdk={SDK} rental={quoteRental} voice={VOICE} />
```

## Booking

The checkout. One island owns the whole flow: it reads the stay the quote handed over from the URL after
hydration, re-quotes, lets the guest edit the stay, extras, rate (only with `policyChoice: "checkout"`),
insurance (the § 7 VVG document step) and voucher, collects the guest data, books, and offers the
payment. **The order button creates a real booking and the payment buttons start a real payment**: never
press them against a real PMS in a test. The legal structure (headless `CHECKOUT_PARTS`,
`assertCheckoutStructure`) holds for every prop combination.

| Prop              | Values                                          | Default              | Meaning                                                                          |
| ----------------- | ----------------------------------------------- | -------------------- | -------------------------------------------------------------------------------- |
| `sdk`             | `SdkSetup`                                      | required             |                                                                                  |
| `rental`          | `BookingRental`                                 | required             | `QuoteRental` plus the summary's facts (below)                                   |
| `countries`       | `CountryGroups`                                 | required             | `countryOptions({ locale, featured })` at build                                  |
| `legal`           | `{ terms: LegalLink, privacy: LegalLink }`      | required             | `{ href, document? }` each; a `document` opens in a dialog                       |
| `returnPath`      | string                                          | required             | the page that renders `BookingReturn`, never the checkout                        |
| `payment`         | `BookingPayment`                                | required             | see below; `methods` has no default                                              |
| `layout`          | `"rail"`, `"stacked"`                           | `"rail"`             | summary rail beside the form, or one column with a sticky order bar              |
| `dayPrices`       | `"rounded"`, `"exact"`, `"none"`                | `"rounded"`          | the date editor's calendar; the same value as on `Quote`                         |
| `discountDisplay` | `"rows"`, `"struck-total"`                      | `"rows"`             | inert on v9                                                                      |
| `policyChoice`    | `"rental-page"`, `"checkout"`                   | `"rental-page"`      | the carried rate is applied and stated, or the offered rates are a picker here   |
| `consents`        | `{ terms?, privacy?, additional? }`             | notice, notice, none | how the legal texts are put before the guest (below)                             |
| `requiredFields`  | `OptionalGuestField[]`                          | none                 | required on top of the minimum (below)                                           |
| `extras`          | `{ layout?, visible?, media?, fallbackImage? }` | a plain list         | how the extras present (below)                                                   |
| `allowOption`     | boolean                                         | `false`              | accept `?kind=option` (Quote's `{ kind: "option" }`): a non-binding reservation  |
| `guests`          | `GuestOptions`                                  | headless defaults    | the site's `GUESTS`                                                              |
| `minStayNights`   | number                                          | none                 | the same value as on `Quote`                                                     |
| `defaultCountry`  | `CountryCode`, `"detect"`                       | `"detect"`           | the starting Land; detect reads the browser languages after hydration            |
| `fallbackCountry` | `CountryCode`                                   | `"DE"`               | when detection names none of `countries`, and the dial code of a national number |
| `policyCopy`      | `Partial<PolicyCopy>`                           | none                 | the rate rows' strings, as on `Quote`: pass the same object to both              |
| `header`          | slot                                            | none                 | brand content at the top of the summary                                          |
| `summaryFooter`   | slot                                            | none                 | brand content under the price (a trust note)                                     |

- `rental` (`BookingRental`): the quote's `id`, `maxGuests`, `petsAllowed`, plus `name`, `href` (the
  rental page the fallback links back to), `location`, `images` (the first five are shown and parked for
  the return page) and `facts` (pre-formatted strings such as "3 Schlafzimmer").
- `legal`: `terms` are accepted by booking, `privacy` is information (Art. 13 DSGVO). A `LegalLink`
  with a `document` (headless `fetchLegalDocuments` at build) opens in a dialog; without one it is a
  plain link in a new tab.
- `consents`: `terms` and `privacy` each `"notice"` (default: one terms line under the total) or
  `"checkbox"` (a required checkbox; for privacy an acknowledgement, never a consent to processing);
  `additional` adds required checkboxes for terms that apply to this rental only (`AdditionalTerms`:
  `id`, `label` with `{link}`, `linkLabel`, `href`).
- `requiredFields` (`OptionalGuestField[]`: `"salutation"`, `"phone"`, `"housenumber"`): required on top
  of forename, surname, email, street, postal code, city and country.
- `extras` (`BookingExtras`): `layout` `"list"` (default) or `"carousel"` (image cards), `visible` (rows
  before "Mehr Extras anzeigen (N)"), `media` (`ServiceMedia`: an image or description per PMS service,
  matched by id or label; the SDK's own win) and `fallbackImage`. On v9 the SDK sends the description
  and images maintained per service in the be-on! Hub: do not add `media` or `fallbackImage` there, have
  the user maintain the extras in the Hub, demo hub and production hub each (see the website SDK's
  `quote.md`, Additional Services), and remove existing ones once the Hub has the content. On v10 the PMS
  sends neither, so `media` is the only source.
- `payment` (`BookingPayment`): `methods` (`"transfer"`, `"card"`, `"debit-card"`, `"paypal"`; required: a
  hint the business does not stand behind is a false statement under the order button; `[]` draws no hint
  row), `cardBrands` (`"visa"`, `"mastercard"`, `"amex"`, drawn beside `"card"` only), `layout` (`"list"`
  default, `"disclosure"`), `breakdown` (`"lines"` default, `"amounts"`), `bankHolderFallback` (the
  transfer account holder when the PMS sends none, as on the Impressum). Insurance the PMS offers cannot be
  hidden here: a site that wants none switches it off at the source.

**Parts** `BOOKING_PARTS` (an object keyed by name): root `checkout` (`data-layout`, `data-booking-kind`,
`data-phase`), `checkout-summary`, `checkout-summary-header`, `checkout-main`, `checkout-submit-bar`,
`checkout-rental`, `checkout-stay` (`data-edit`), `checkout-price`, `checkout-voucher` (`data-state`),
`checkout-issue`, `checkout-section` (`data-section="extras|policy|insurance|contact|submit"`,
`data-busy`), `checkout-service`, `checkout-insurance-option`, `checkout-insurance-documents`,
`checkout-notice` (`data-notice`), `checkout-consent-row` (`data-consent`), `checkout-legal-link`,
`checkout-submit-total`, `checkout-payment-hints`, `checkout-payment-glyph` (`data-method`),
`checkout-confirmation` (`data-kind`), `checkout-schedule`, `checkout-payment-option`,
`checkout-bank-transfer`, `checkout-fallback` (`data-reason`), `checkout-legal-dialog`,
`checkout-stay-dialog`, and more. The legal parts a custom checkout must keep are headless
`CHECKOUT_PARTS`: `checkout-price-total`, `checkout-insurance-premium`, `checkout-submit`,
`checkout-terms`, `checkout-consent`, `checkout-insurance-document`, `checkout-insurance-confirm`. Form
fields use `FIELD_PARTS` under the `checkout` root; price lines and rate rows are `quote-line` and
`quote-policy-option`.
**Copy** `DEFAULT_CHECKOUT_COPY` (`CheckoutCopy`, which satisfies headless `ValidationMessages` and
`CheckoutMessages`): `submitLabel` ("Zahlungspflichtig buchen"; a re-voiced label must stay within
headless `SUBMIT_WORDING.booking`, § 312j BGB), `optionSubmitLabel` ("Unverbindlich reservieren",
`SUBMIT_WORDING.option`), the terms line in five fragments that must read as one sentence (`termsPrefix`,
`termsLinkLabel`, `termsConjunction`, `privacyLinkLabel`, `termsSuffix`: the terms are accepted, the
privacy notice is information; the conjunction and the suffix carry their own leading space, so
`termsConjunction: ", es gelten die"` puts a comma right after the AGB link), `privacyAcknowledgeLabel`, `guestsLabel`, `paymentMethodTransfer` …,
`paymentNoteChoice`, `submitNote` (empty and then not rendered), `confirmationHeading`,
`confirmationNumberLine`. The rate rows read `DEFAULT_POLICY_COPY`, with `policyTitle`,
`policyChooseLabel`, `freeCancellationLine`, `dayOne`, `dayMany` and `totalLabel` taken from `copy`, and
`policyCopy` applied last: the object a site passes to `Quote policyCopy` makes the rows read the same
on both pages. The English order-button wording awaits a legal review.
**Events** `voffice:checkout`: `rate-fault` (the same type as `voffice:quote`'s, so one listener serves both), `booked` (`payable: false` when nothing can be
paid online), `payment-started`.

```astro
<Booking
  client:load
  sdk={SDK}
  voice={VOICE}
  rental={{ ...quoteRental, name: rental.name, location: rental.town, href: `/unterkuenfte/${rental.slug}` }}
  countries={countryOptions({ locale })}
  legal={{ terms: { href: "/agb", document: documents.terms }, privacy: { href: "/datenschutz", document: documents.privacy } }}
  returnPath="/buchen/bestaetigung"
  payment={PAYMENT}
  dayPrices={DAY_PRICES}
  policyChoice={POLICY_CHOICE}
  guests={GUESTS}
/>
```

Checkout pages are `noindex` and stay out of the sitemap; that is the site's page, not the component.

### BookingReturn

The payment-return page body, at Booking's `returnPath`. A calm placeholder on the server; after mount
headless `readPaymentReturn(location.search)` decides: `"booked"` (this tab's snapshot: photos, rental,
stay, party, number, total), `"returned"` (a provider return without a snapshot: the thank-you alone) or
`"none"` (a typed URL: no thanks for a booking that does not exist). It renders the page's h1 and never
claims a payment outcome; the `<title>` and the chrome are the site's.

| Prop         | Values                                                         | Default                                          | Meaning                                             |
| ------------ | -------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------- |
| `nextLinks`  | `BookingReturnLink[]` (`label`, `href`, `primary?`, `newTab?`) | none                                             | onward links; at most the first `primary` is filled |
| `homeHref`   | string                                                         | `"/"`                                            | the `"none"` state's way out                        |
| `imageSizes` | string                                                         | `"(min-width: 64rem) 34rem, calc(100vw - 3rem)"` | `sizes` of the photo strip; must be true            |
| `shared`     | `SharedCopyOverrides`                                          | none                                             | `common.newTabHint`                                 |

**Parts** (in `BOOKING_PARTS`): `booking-return` (`data-state="loading|booked|returned|none"`,
`data-booking-kind`), `booking-return-badge`, `booking-return-headline`, `booking-return-accent`,
`booking-return-intro`, `booking-return-cancelled`, `booking-return-invitation`, `booking-return-links`,
`booking-return-link` (`data-kind="primary|rental|secondary|home"`), `booking-return-summary`,
`booking-return-images`, `booking-return-rental`, `booking-return-facts`, `booking-return-fact`
(`data-fact`). **Copy** `DEFAULT_BOOKING_RETURN_COPY`: `headline`, `intro`, `optionHeadline`,
`paymentCancelledNote` (after a cancelled provider checkout: the booking stands), `emptyHeadline`,
`emptyIntro`; `scriptAccent` and `invitation` are empty by design and then not rendered.

```astro
<BookingReturn client:load voice={VOICE} homeHref="/" nextLinks={[{ label: "Weitere Unterkünfte", href: "/suchen" }]} />
```

## ContactForm

The contact and inquiry form (the `contact` surface). It renders headless `createContactForm` state: the
fields, validation, the composed message and the SDK issue routing are headless's. A real submit posts a
request into the PMS and cannot be undone: never submit it against a real backend in a test. The submit
is disabled until hydration (no pre-hydration GET of personal data into logs), and the SDK is imported
and claimed on the first submit, so a contact page ships no SDK code up front. Privacy is a notice,
never a consent gate.

| Prop             | Values                    | Default                                  | Meaning                                                                                     |
| ---------------- | ------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------- |
| `sdk`            | `SdkSetup`                | required                                 |                                                                                             |
| `supportEmail`   | string or `null`          | required                                 | named in the send-failure line; `null`: no publishable inbox                                |
| `countries`      | `CountryGroups`           | required                                 | `countryOptions({ locale, featured })` at build                                             |
| `subject`        | string                    | `copy.defaultSubject` ("Kontaktanfrage") | recorded on the request in the PMS (≤ 200 characters); set it per form                      |
| `fields`         | `ContactFieldSpec[]`      | `DEFAULT_CONTACT_FIELDS`                 | the field list in render order (below)                                                      |
| `defaultCountry` | `CountryCode`, `"detect"` | `"detect"`                               | the starting Land; a `defaultValue` on the `country` spec wins                              |
| `privacyHref`    | string or `null`          | `null`                                   | a notice above the submit linking the privacy page (new tab); set it once the page resolves |
| `headingLevel`   | `1`–`6`, `"none"`         | `2`                                      | the success panel's heading                                                                 |
| `shared`         | `SharedCopyOverrides`     | none                                     | `common.optionalSuffix`, `common.newTabHint`                                                |

The default fields are salutation, forename, surname, email, phone, country and message. A field spec
is a built-in name (`"phone"`), a built-in with a decision changed
(`{ field: "phone", required: true, label: "Telefon (tagsüber)", width: "half" }`), an extra the API has
no slot for (`{ extra: "arrival", kind: "period", label: "Reisezeitraum", allowFlexible: true }`; kinds
`text`, `multiline`, `email`, `tel`, `number`, `date`, `select`, `checkbox`, `period`), or a group heading
(`{ group: "Ihre Unterkunft" }`). Extras become one line each of the message; text, number, date and
select extras may prefill from a URL parameter (`prefillParam`), built-in fields never.

**Parts** `CONTACT_PARTS`: `contact-form` (`data-busy`), `contact-group`, `contact-actions`,
`contact-privacy`, `contact-privacy-link`, `contact-status` (role alert), `contact-submit` (`data-busy`),
`contact-success` (role status), `contact-success-heading`, `contact-success-body`,
`contact-send-another`; inside each field `FIELD_PARTS`.
**Copy** `DEFAULT_CONTACT_COPY` (`ContactFormCopy`, which satisfies headless `ContactFormMessages`): the
field labels, `defaultSubject`, `submitIdle` ("Nachricht senden"), `submitBusy`, `successHeading`,
`successBody` ("Wir melden uns zurück."; no response-time promise), `sendAnother`, the privacy notice
split around its link (`privacyNoticeText`, `privacyLinkLabel`, `privacyNoticeSuffix`), and the
validation messages.
**Events** `voffice:contact` `{ type: "sent", subject }`, never a field value.

```astro
<ContactForm
  client:load
  sdk={SDK}
  voice={VOICE}
  supportEmail="info@example.org"
  countries={countryOptions({ locale })}
  subject="Anfrage Eigentümer"
  privacyHref="/datenschutz"
  fields={["salutation", "forename", "surname", "email", { field: "phone", required: true }, "country",
    { group: "Ihre Unterkunft" }, { extra: "rooms", kind: "number", label: "Anzahl Wohnungen", min: 1 }, "message"]}
/>
```

## RichText

One renderer for sanitized PMS text: descriptions, property texts and legal documents. Headless turns
the text into `RichBlock`s (`parseTextBlocks`, `normalizeStructuredDocument`, `fetchLegalDocuments`) and
has whitelisted every link; React escapes every text. No hooks: render it without a client directive.
External links (http, https) open in a new tab with `rel="noopener noreferrer"`; site-relative, `mailto`
and `tel` links do not.

| Prop           | Values                 | Default     | Meaning                                                                        |
| -------------- | ---------------------- | ----------- | ------------------------------------------------------------------------------ |
| `blocks`       | `RichBlock[]`          | required    | renders nothing for none                                                       |
| `headingLevel` | `1`–`6`, `"none"`      | `2`         | where the document's level-2 headings render; `"none"`: every heading a `<p>`  |
| `firstHeading` | `"section"`, `"title"` | `"section"` | `"title"`: a leading heading is the page title, one level above (an h1)        |
| `title`        | string                 | none        | `firstHeading="title"`: drawn as that title when the first block is no heading |

The document's level-3 and level-4 headings follow one and two levels below `headingLevel`, capped at 6.
Headless clamps a document's headings to levels 2–4 (an H1 arrives as level 2), and
`normalizeStructuredDocument` drops a leading heading that repeats the document's subject. A legal
document that opens with its own, different title takes `firstHeading="title"`: that heading renders
one level above `headingLevel` with `data-level="1"`, and the page draws no title of its own. A
document of that kind can still open with text (or lose its leading heading to that subject check);
pass `title` (`LegalDocument.title`) and RichText draws it as that heading in its place, so the page
keeps its h1.
**Parts** `RICH_TEXT_PARTS`: `rich-text`, `rich-text-heading`, `rich-text-paragraph`, `rich-text-list`,
`rich-text-list-item`, `rich-text-link`, `rich-text-rule`.

```astro
---
const { terms } = await fetchLegalDocuments(sdk, locale);
---
<h1>{terms?.title ?? "AGB"}</h1>
{terms && <RichText blocks={terms.blocks} />}
<!-- or, where the document opens with its own title: -->
{terms ? <RichText blocks={terms.blocks} firstHeading="title" title={terms.title} /> : <h1>AGB</h1>}
```

## Maps

The entry `@v-office/website-components/map`, so a page without a map never pulls the map code.
`maplibre-gl` (the optional peer) loads on demand; the page imports `css/map.css`. Every map takes
`MapCommonProps`:

| Prop              | Values                           | Default                                                 | Meaning                                                  |
| ----------------- | -------------------------------- | ------------------------------------------------------- | -------------------------------------------------------- |
| `loadOn`          | `"click"`, `"near"`, `"consent"` | `"click"`                                               | when tiles may load (below)                              |
| `consentCategory` | string                           | none                                                    | required with `"consent"` (the render throws without it) |
| `styleUrl`        | string                           | headless `DEFAULT_MAP_STYLE_URL` (OpenFreeMap "Bright") | a brand style is a JSON file the site hosts              |
| `previewImage`    | string                           | none                                                    | a site asset in the placeholder, never a third-party URL |
| `privacyHref`     | string                           | none                                                    | the privacy page linked from the placeholder             |

`loadOn="click"` renders a placeholder with a load button: nothing reaches the tile host before the
click, and one click loads every map on the page (the next page asks again). `"near"` loads once the map
comes within a screen of the viewport, for a site whose consent manager already covers the tile vendor.
`"consent"` is the click placeholder that also loads by itself once the site's consent manager grants
`consentCategory`: the site keeps the granted categories on `<html>` (`CONSENT_GRANTED_ATTRIBUTE`,
`data-consent-granted="maps analytics"`; under ClientRouter set it again after each swap) and dispatches
`CONSENT_EVENT` (`voffice:consent`, `{ type: "granted" | "revoked", categories }`) on every change. A
revocation stops future loads and leaves a built map in place. The package never decides a site's
consent categories.

```ts
import {
  CONSENT_EVENT,
  CONSENT_GRANTED_ATTRIBUTE,
  type ConsentEventDetail,
} from "@v-office/website-components";

// In the site's consent host, on every change (and again after each ClientRouter swap):
document.documentElement.setAttribute(CONSENT_GRANTED_ATTRIBUTE, granted.join(" "));
const detail: ConsentEventDetail = { type: "granted", categories: ["maps"] };
window.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail }));
```

**Parts** `MAP_PARTS`: roots `search-results-map` (`data-state="waiting|empty|ready"`), `rental-map`,
`location-map`; `map-inline`, `map-canvas`, `map-controls`, `map-zoom-in`, `map-zoom-out`, `map-expand`,
`map-fullscreen`, `map-close`, `map-pin` (`data-priced`, `data-state="idle|active|open"`), `map-cluster`
(`data-size`, `data-state`), `map-group` (`data-expanded`), `map-marker` (`data-shape="pin|dot"`),
`map-popup`, `map-popup-close`, `map-caption`, `map-empty`, `map-consent`, `map-consent-load`, `map-pill`
(`data-state="hidden|visible"`). Wiring markers: `data-search-map-open`, `data-map-open`, `data-map-pill`,
`data-lat`/`data-lng` on the rental map, `data-consent-embed` on a consent placeholder.

### SearchResultsMap

Every result of the page's search on one map: an inline surface shown from lg (a site column decides
where) and a fullscreen layer that opens from its expand button and from every `[data-search-map-open]`
on the page (`SearchMapPill`, or a site's own toggle). Both read the page's one search store: the list
follows the map once the guest moves it, pins state the price as the cards do, and hovering a card
lights its pin.

| Prop             | Values                                 | Default                                               | Meaning                                                                            |
| ---------------- | -------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `storeKey`       | string                                 | `"search"`                                            | the same key as `SearchResults`                                                    |
| `card`           | `RentalCardOptions`                    | none                                                  | the site's `CARD`: pins and the popup state the price as the cards do              |
| `pins`           | `PinLabelOptions` (`price?`, `cents?`) | from `card`                                           | e.g. `{ cents: "round" }` for whole-unit pins while the cards keep the exact total |
| `listFollowsMap` | boolean                                | `true`                                                | the list shows the results in view once the guest moved the map                    |
| `cluster`        | `ClusterOptions` or `false`            | `{ radius: 54, maxZoom: 11 }`                         | count-only bubbles; `whenPriced` widens priced sets                                |
| `sameSpot`       | `"fan"`, `"group"`                     | `"fan"`                                               | results on one spot; `"group"` for single-building portfolios                      |
| `fit`            | `FitOptions`                           | `{ padding: 56, maxZoom: 12, excludeOutliers: true }` | the first frame                                                                    |
| `emptyView`      | `{ center: [lng, lat], zoom }`         | none                                                  | the camera for a search with nothing to pin; unset: the empty notice, no tiles     |
| `scrollZoom`     | boolean                                | `true`                                                | wheel zoom on the inline map (a sticky column); the fullscreen map always zooms    |
| `regionScope`    | `"keep"`, `"release-on-move"`          | `"keep"`                                              | the first move drops the `?region=` scope so neighbouring results come back        |
| `shared`         | `SharedCopyOverrides`                  | none                                                  | the popup card's words                                                             |

**Copy** `DEFAULT_SEARCH_RESULTS_MAP_COPY`: `mapLabel`, `expand`, `close`, `cluster`, `group`, `pin`
(`{name}`, `{price}`), `empty`, `consentNotice` (`{provider}`), `consentLoad`, `consentPrivacy`,
`provider` (set it when a brand style uses another tile host).

### SearchMapPill

The floating map button below lg. `data-search-map-open` is the whole wiring. Welded to the viewport, it
hides itself (visibility and opacity, so it also leaves the tab order) while nothing can be pinned, once
the element it yields to reaches it (by default the footer), and, with `showFrom`, until the results
reach it.

| Prop       | Values       | Default                     | Meaning                                                                     |
| ---------- | ------------ | --------------------------- | --------------------------------------------------------------------------- |
| `storeKey` | string       | `"search"`                  | the page's store                                                            |
| `showFrom` | CSS selector | none (from the first frame) | stays hidden until that element's top has risen past the pill (a hero page) |
| `yieldTo`  | CSS selector | `"footer"`                  | what the pill yields to once it reaches the pill's top edge                 |

**Copy** `DEFAULT_SEARCH_MAP_PILL_COPY`: `label`.

```astro
---
import { SearchMapPill, SearchResultsMap } from "@v-office/website-components/map";
---
<div class="lg:grid lg:grid-cols-[minmax(0,1fr)_40%] lg:gap-8">
  <div class="min-w-0"><SearchResults client:load … /></div>
  <aside><div class="lg:sticky lg:top-24 lg:h-[calc(100dvh-8rem)]">
    <SearchResultsMap client:load card={CARD} voice={VOICE} privacyHref="/datenschutz" />
  </div></aside>
</div>
<div class="lg:hidden"><SearchMapPill client:load voice={VOICE} /></div>
```

### RentalMap

The rental page's map: one marker on the coordinates the PMS publishes, the address line the site
decided, and the rental's accessible name. Renders nothing without coordinates (a page that opens it from
its own buttons hides them then too). Takes `PlaceMapProps`, shared with `LocationMap`:

| Prop         | Values                         | Default                          | Meaning                                                                                  |
| ------------ | ------------------------------ | -------------------------------- | ---------------------------------------------------------------------------------------- |
| `rental`     | `RentalDetail`                 | required                         |                                                                                          |
| `address`    | `"street"`, `"town"`, `"none"` | required                         | the site's `ADDRESS` (or `"none"`: no line); throws when missing; never moves the marker |
| `fullscreen` | `"button"`, `"none"`, `"only"` | `"button"`                       | inline with an expand button, inline only, or no inline map (below)                      |
| `marker`     | `"pin"`, `"dot"`               | `"pin"`                          | a teardrop on the spot, or a centred dot (a square is a dot restyled)                    |
| `zoom`       | `{ inline?, fullscreen? }`     | `{ inline: 12, fullscreen: 14 }` | the inline map never wheel-zooms; the fullscreen map does                                |
| `popup`      | `"name"`, `"none"`             | `"name"`                         | activating the marker shows the place name                                               |
| `name`       | string                         | none                             | distinguishes several maps on a page (below)                                             |

With `fullscreen="only"` the fullscreen layer opens from every element with `data-map-open` whose value is
the map's `name` (empty for an unnamed map); the layer's URL hash is `#map-<name>` (default
`#map-fullscreen`). `address="town"` hides the address text, not the marker: the results map pins every
rental at the PMS coordinates anyway, so a hidden location needs coarse coordinates in the PMS. The
line renders above the map; a page that wants `RentalDistances` between the address and the map passes
`address="none"` and prints `rentalAddressLine(rental, ADDRESS)` (headless) itself.
**Copy** `DEFAULT_PLACE_MAP_COPY` (with `LocationMap`): `rentalLabel`, `rentalLabelInTown`,
`fullscreenLabel`, `expand`, `close`, `zoomIn`, `zoomOut`, `consentNotice`, `consentLoad`,
`consentPrivacy`, `provider`, `mapLabel`.

```astro
<section id="map">
  <RentalMap client:visible rental={rental} address={ADDRESS} voice={VOICE} privacyHref="/datenschutz" />
  <RentalDistances rental={rental} voice={VOICE} />
</section>
<!-- or the distances between the address and the map -->
<section id="map">
  <p>{rentalAddressLine(rental, ADDRESS)}</p>
  <RentalDistances rental={rental} voice={VOICE} />
  <RentalMap client:visible rental={rental} address="none" voice={VOICE} privacyHref="/datenschutz" />
</section>
```

### LocationMap

A map of fixed places: an office, the arrival point, a facility, a location's units. Places on one spot
share one marker naming them all. It replaces third-party map iframes, which loaded the vendor before any
consent. Takes `PlaceMapProps` (above) plus:

| Prop      | Values                              | Default  | Meaning                                   |
| --------- | ----------------------------------- | -------- | ----------------------------------------- |
| `places`  | `MapPlace[]` (`name`, `lat`, `lng`) | required | site data; renders nothing when empty     |
| `caption` | string                              | none     | a line under the map (the office address) |

```astro
<LocationMap client:visible places={[{ name: "Büro", lat: 51.0, lng: 10.0 }]} caption="Hafenstraße 1" marker="dot" />
```

## Bindings and helpers

A binding is `useSyncExternalStore` over a headless store, for a site that renders its own UI over the
package's rules. Importing a surface's binding without its component makes that surface `custom` in the
site manifest.

The surface bindings:

- `useSearchForm(options)` → `{ state, form }`, over `createSearchForm`. The options are headless
  `SearchFormOptions`: the contract spread in (`...SEARCH_CONTRACT`), `fields`, region values, `dates`,
  `guests`, `childAges`, `preset`. It reads the URL after hydration and again on Back/Forward; a native
  submit passes `location.search` to `form.submit`, so what the page changed in place rides along.
- `useSearchStore(key = "search")` → `{ state, store }` or `null`: the page's one search store. `null` on
  the server and until a `SearchResults` (or the site's own `acquireSearchStore`, under
  `DEFAULT_STORE_KEY` for the package satellites to attach) created it; it never creates a store and
  never searches. `browserHistory()` is the browser URL as the store's
  `HistoryPort`, for a site's own `createSearchStore` (browser only, in an effect).
- `useQuoteFlow({ sdk, rental, role?, … })` → `{ state, flow }`, over `createQuoteFlow`, shared by every
  island of the rental. `role: "widget"` (default) applies its settings, reads the URL handover once and
  mirrors the state back; `"satellite"` (an occupancy calendar) only joins. Dispatches `voffice:quote`.
- `useCheckoutFlow({ sdk, rental, locale, returnPath, messages, … })` → `{ state, flow }`, over
  `createCheckoutFlow`, created after hydration from `location.search`. Dispatches `voffice:checkout` and
  writes the edited stay into the URL. Call `book()` and `pay()` from a click, never from an effect.
- `useGuestForm(options)` → `{ state, form }`, over `createGuestForm`: the checkout's guest data, with
  the Land preselected from `navigator.languages` after hydration.
- `useContactForm({ sdk, … })` → `{ state, form }`, over `createContactForm` (`ContactFormOptions` plus
  the SDK setup). The SDK loads on the first submit; a sent request dispatches `voffice:contact`.

The helpers:

- `useFavorites(storageKey | null)` → `{ state, favorites }`: the page's Merkliste under the key, shared
  with the layout's binder; `state.ids` is empty on the server and in the hydrating render. A heart inside
  a link calls `event.preventDefault()` before `favorites.toggle(id)`.
- `useEnhance(bind, deps)` → a ref: binds a headless `./enhance` engine (`bindStrip`, `bindLayer`, …) to
  the ref's element after mount and disposes it on unmount or when `deps` change.
- `useConsent(category)` → boolean: true once the site's consent manager granted the category; false
  on the server and in the hydrating render.
- `useSdk(setup, mode?)` / `useLazySdk(setup, mode?)` → a getter for the page's shared instance (one
  1 req/s queue and one cache per setup and mode). Call the getter in an effect or a handler, never
  during render; the lazy one imports the SDK on its first call.
- `I18nProvider` / `useI18n()`: a surface wraps its tree once; a site part rendered inside reads the
  resolved `locale`, `voice` and `shared` copy.
- `resolveCopy`, `resolveShared`, `fill`, `fillParts` (renders chosen tokens in
  `<strong data-part="emphasis">`), `dateFnsLocale` (the calendar grids' locale), `GlyphIcon` (a headless
  `Glyph` as inline SVG).

```tsx
// A site's own result count over the page's search store.
import { useSearchStore } from "@v-office/website-components";

export function OwnCount() {
  const binding = useSearchStore(); // null on the server and until SearchResults created the store
  // Nothing while pages load, and nothing beside an error or empty outcome.
  if (!binding?.state.complete || binding.state.outcome) return null;
  return <p>{binding.state.count} Unterkünfte</p>;
}
```

A site's own UI over a flow dispatches the `voffice:*` events itself when it uses the headless stores
directly; through a binding it gets them for free. A custom checkout keeps the `CHECKOUT_PARTS` and
passes `assertCheckoutStructure`; its build refuses a `checkout-submit` that neither `Booking` nor
`createCheckoutFlow` renders.

## Development

```sh
pnpm install     # at the repository root; headless, website-sdk and site-manifest are workspace links
pnpm check       # tsc, oxlint (+ react-doctor), oxfmt --check
pnpm build       # tsdown → dist/, tailwindcss → css/components.css
pnpm test        # vitest; then test/dist.node.mjs imports the build by name, as a site does (build first)
pnpm fmt         # oxfmt · pnpm lint:fix for oxlint's fixes · pnpm dev for tsdown --watch
```

tsc and the tests need the workspace SDK and headless built (`pnpm --filter @v-office/sdk-core --filter
@v-office/website-sdk --filter @v-office/website-headless --filter @v-office/site-manifest run build`).
Contributor rules and the file layout are in `AGENTS.md`: every source file opens with a `PUBLIC SURFACE`
banner documenting its props and types, which is the ground truth this README summarises. Publishing:
see [docs/internal/releasing/overview.md](../../docs/internal/releasing/overview.md).
