# The browse channel

> One browse session across the search island, results grid, rail, map, and quick view — specific emitted events, the event map, the results.* and sheet.* commands, BrowseState + useBrowseState, the two vacation rental sets, the data-kismet join attributes, and the browse-channel and map-view contracts.




On every Kismet site the search island, results grid, rail, map, and quick view
must stay in sync: switching to Map shows the map, hovering a card lights its
pin, and clicking a pin opens the quick view. The `browse-channel` contract
keeps this one session — with three separable layers:

1. **Interactions EMIT specific events** — past-tense facts in Object Action
   form (`Map Pin Clicked`). Elements never dispatch commands.
2. **An event map maps events to commands** — plain data, shipped as
   `defaultBrowseEventMap`, overridable by editing the data.
3. **Commands own the state** — each changes exactly one field, with exact
   rollback. State lives in one owner (`BrowseState` + `useBrowseState()`,
   from `@kismet-tech/sdk/react`), never in local component state.

## The events

Interaction events are declared once in the emit vocabulary
(`EMIT_VOCABULARY` in `@kismet-tech/sdk/contracts`). `emit()` validates the
payload against the vocabulary's zod schema, notifies listeners, and forwards
to the telemetry sink when one is configured — an invalid payload throws.

| Event | Payload | Fired by |
|---|---|---|
| `Map Pin Clicked` | `{ vacationRentalSlug }` | activating a `[data-kismet-pin]` |
| `VacationRental Card Clicked` | `{ vacationRentalSlug }` | activating a `[data-kismet-card]` |
| `Map Pin Hovered` | `{ vacationRentalSlug \| null }` | pointer over / out of a pin |
| `VacationRental Card Hovered` | `{ vacationRentalSlug \| null }` | pointer over / out of a card or hover element |
| `Layout Toggle Clicked` | `{ layout }` | the results layout control |
| `Quick View Close Clicked` | `{}` | the quick view's close control |
| `Booking Requested` | booking request | the booking flow — **tracking-only** |

Hover-out emits the same Hovered event with `vacationRentalSlug: null`. An
event no map entry reacts to is **tracking-only** — it reaches analytics and
never drives UI. `Booking Requested` is flagged `trackingOnly` in the
vocabulary; the coverage check keeps the vocabulary and the default map
honest (every interaction event is mapped, or flagged tracking-only).

```ts
import { emit } from '@kismet-tech/sdk';

emit('Map Pin Clicked', { vacationRentalSlug: 'fern-ridge-cabin' });
```

To listen from React, `useKismetEvent` is on the `./react` entry:

```tsx
'use client';

import { useKismetEvent } from '@kismet-tech/sdk/react';

function Analytics() {
  useKismetEvent((event) => console.log(event.name, event.payload));
  return null;
}
```

## The event map

The map is plain data: command id → the events that fire it, plus a payload
mapping (the input-map / keybinding pattern). The SDK ships
`defaultBrowseEventMap`:

| Command | Fired by events |
|---|---|
| `sheet.open` | `Map Pin Clicked`, `VacationRental Card Clicked` |
| `results.setHovered` | `Map Pin Hovered`, `VacationRental Card Hovered` |
| `results.setLayout` | `Layout Toggle Clicked` |
| `sheet.close` | `Quick View Close Clicked` |

`<BrowseState>` uses the default map unless you pass `eventMap`. Passing
`{}` means every event still fires — and nothing reacts.

## The commands

Commands are imperative, one per outcome, each changing exactly one store
field, all reversible, all registered in the shared action dispatcher and
projected to WebMCP — so a human control and a WebMCP agent drive the exact
same transitions.

| Command | Input | Changes |
|---|---|---|
| `sheet.open` | `{ slug }` | `openSlug` |
| `sheet.close` | `{}` | `openSlug` → `null` |
| `results.setLayout` | `{ layout: 'grid' \| 'map' }` | `layout` |
| `results.setHovered` | `{ slug \| null }` | `hoveredSlug` |

```ts
import { defaultKismetActionDispatcher } from '@kismet-tech/sdk';

// Own code (choosers, agents) dispatches commands directly:
await defaultKismetActionDispatcher.dispatch('sheet.open', { slug: 'fern-ridge-cabin' }, { source: 'webmcp' });
```

**Ownership rule** (`conformance/browse-command-ownership`): shared fields
change only through commands — never by writing two fields at once, and
every rollback restores the exact prior value, so A→B→A ends at A.

`layout` and `openSlug` persist through the canonical stay URL's UI-only keys
(`layout`, `sheet`) and are written shallow — a layout switch or quick-view
open MUST NOT re-read results.

## The one state owner

```tsx
'use client';

import { BrowseState, useBrowseState } from '@kismet-tech/sdk/react';

<BrowseState resultVacationRentals={resultVacationRentals} openableVacationRentals={openableVacationRentals}>
  <SearchIsland /> {/* emits events */}
  <ResultsGrid />  {/* receiver */}
  <StayMap />      {/* receiver */}
  <BookableProductQuickView />    {/* receiver */}
</BrowseState>;

function StayMap() {
  const { layout, hoveredSlug, openSlug, resultVacationRentals } = useBrowseState();
  // Re-render on every browse change; render nothing when layout is 'grid'.
}
```

`useBrowseState()` exposes `layout`, `hoveredSlug`, `openSlug`, the two
vacation rental sets, and three **emit helpers** for senders that live
outside a joined element: `emitLayoutToggle(layout)` (the island's layout
control), `emitQuickViewClose()` (the sheet's close control), and
`emitVacationRentalCardClick(slug)` (a building-pin chooser row). Receivers
never dispatch — a click that must change state emits its event and the
event map reacts. For commands outside an interaction (a WebMCP tool, a
chooser's async flow), dispatch on the action dispatcher directly.

### Recipe: the default wiring

Mount `BrowseState`, render pins and cards with the join attributes, and
give the island's layout toggle an `emit` call. That is the whole
integration — the default map opens the sheet from pin and card clicks,
highlights on hover, and switches layouts.

### Recipe: a pin that scrolls the list instead

Keep the pin's emission, and remap the reaction — the event is the contract,
the reaction is data. Drop the `sheet.open` entry from the default map so
clicks stop opening the sheet, and let the site's own code scroll the grid
into view by listening with `useKismetEvent`:

```tsx
'use client';

import { defaultBrowseEventMap } from '@kismet-tech/sdk';

// Hover, layout, and close keep their default reactions; pin and card
// clicks no longer open the sheet.
const { 'sheet.open': _removed, ...scrollToListEventMap } = defaultBrowseEventMap;

function ScrollToList() {
  useKismetEvent((event) => {
    if (event.name === 'Map Pin Clicked') scrollToGridCard(event.payload.vacationRentalSlug);
  });
  return null;
}

<BrowseState eventMap={scrollToListEventMap}>
  <ScrollToList />
  …
</BrowseState>;
```

### Recipe: a tracking-only event

`Booking Requested` is already in the vocabulary with `trackingOnly: true`;
emit it from the booking flow and no event map may react to it. Add a new
tracking-only event by appending a vocabulary entry with `trackingOnly:
true` — the coverage check then guards it against accidental mapping.

## Joining from any element

Any element — a server-rendered card, a map pin, a plain button, a rail
photo — joins the channel by carrying `data-kismet-slug` (itself or on the
nearest ancestor) plus one join attribute:

- `data-kismet-pin` — emits `Map Pin Clicked` / `Map Pin Hovered`;
- `data-kismet-card` — emits `VacationRental Card Clicked` / `VacationRental Card Hovered`;
- `data-kismet-hover` — a hover-only join (a rail photo), emitting `VacationRental Card Hovered`.

One delegated listener inside `BrowseState` serves them all, and delegation
ONLY emits — reactions belong to the event map. Joining never registers an
element as a result.

```html
<button data-kismet-pin data-kismet-slug="fern-ridge-cabin">…</button>
<article data-kismet-card data-kismet-slug="pacific-coast-house">…</article>
<img data-kismet-hover data-kismet-slug="deschutes-river-lodge" … />
```

## The two vacation rental sets

- **`resultVacationRentals`** — the vacation rentals on the map, grid, and rail. Sort and filter
  apply to these.
- **`openableVacationRentals`** — anything that can open the sheet, such as related vacation rentals
  or review-strip vacation rentals.

An **openable-only** vacation rental (in `openableVacationRentals` but not `resultVacationRentals`) can
open the quick view but never gets a pin or a grid slot.

## The map-view contract

`map-view` builds on the browse channel. Its MUSTs are the map's own facts:
each `[data-kismet-pin]` emits `Map Pin Clicked` with its slug
(`conformance/browse-pin-emits`, captured by the conformance runner through
the `__KISMET_EVENTS__` test sink); vacation rentals at the same point (equal to 4 decimal places)
form one "N vacation rentals · from $X" building pin whose click opens a chooser, while a
single-vacation rental building opens the sheet directly; zoom clusters stay separate from
building pins; dated pins demote vacation rentals unavailable for the active stay; the
initial view fits the main cluster, ignoring outliers; with no maps key the map
still shows pins, just no tiles; and pins are buttons with name + price,
reachable by keyboard. Reactions — a pin click opening the quick view and
card↔pin hover both ways — are SHOULDs under the default event map (warning
only, never blocking). "Search this area" is opt-in (SHOULD). The pin math
and the map component are not in the SDK core — they are the `KismetMap`
work (Linear KIS-17820, KIS-17837).

## Where to go next

- [What is a contract](https://developers.kismet.travel/sdk/contracts.md) — requirement levels and the
  substitution rule.
- [The canonical stay URL](https://developers.kismet.travel/sdk/stay-query.md) — the `stay-query` contract the
  browse channel builds on.
- [Recipes, contracts, and implementation tests](https://developers.kismet.travel/sdk/recipes.md) — declare
  implementations and run the suite.
