The browse channel
View .mdOn 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:
- Interactions EMIT specific events — past-tense facts in Object Action
form (
Map Pin Clicked). Elements never dispatch commands. - An event map maps events to commands — plain data, shipped as
defaultBrowseEventMap, overridable by editing the data. - 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
Section titled “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).
import { emit } from '@kismet-tech/sdk';
emit('Map Pin Clicked', { vacationRentalSlug: 'fern-ridge-cabin' });To listen from React, useKismetEvent is on the ./react entry:
'use client';
import { useKismetEvent } from '@kismet-tech/sdk/react';
function Analytics() { useKismetEvent((event) => console.log(event.name, event.payload)); return null;}The event map
Section titled “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
Section titled “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 |
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
Section titled “The one state owner”'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
Section titled “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
Section titled “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:
'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
Section titled “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
Section titled “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— emitsMap Pin Clicked/Map Pin Hovered;data-kismet-card— emitsVacationRental Card Clicked/VacationRental Card Hovered;data-kismet-hover— a hover-only join (a rail photo), emittingVacationRental 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.
<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
Section titled “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
Section titled “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
Section titled “Where to go next”- What is a contract — requirement levels and the substitution rule.
- The canonical stay URL — the
stay-querycontract the browse channel builds on. - Recipes, contracts, and implementation tests — declare implementations and run the suite.