Kaufmann Commerce UI / Foundation
Astro architecture
Server-first components, stable TypeScript projections, and an optional WebSocket adapter—without inventing a backend contract that does not exist yet.
One foundation, three small layers
@kaufmann/ui owns the canonical tokens and primitives. Commerce compositions build Product Card, Stock Signal, and Cart Panel from them. Transport adapters translate future WebSocket messages into small projection events.
This keeps the API stable: a statically rendered Product Card needs no client runtime. A cart can use the same types and load a script only when live behaviour is actually required.
The commerce documentation is therefore an additional catalogue of the same Kaufmann UI—not a theme, fork, or second primitive system.
---
import { ProductCard } from "@kaufmann/ui";
import "@kaufmann/ui/styles.css";
---
<ProductCard product={offerProjection} />The event seam is typed
Islands stay loosely coupled by talking over DOM custom events. Every name, payload and helper lives in one module — @kaufmann/ui/events — and no kaufmann:* string literal is written anywhere else. The module is framework-free, so a zero-JS seam script can import it without pulling in a framework.
Several events are deliberately two-way: an island both dispatches its own event and listens for it. That is what lets the storefront's zero-results reset move the collection pill and the search input, not merely re-filter the grid.
| Event | Direction | Detail | Dispatched by | Heard by |
|---|---|---|---|---|
kaufmann:collection-change | Two-way | { collectionId } | CollectionFilter, zero-results reset | page filter script, CollectionFilter |
kaufmann:search-query | Two-way | { query } | StoreHeader input, zero-results reset | page filter script, StoreHeader |
kaufmann:cart-open | One-way | — | StoreHeader cart button | useStorefrontCart |
kaufmann:cart-updated | One-way | { count, subtotal, currency, open } | useStorefrontCart | StoreHeader (badge + aria-expanded) |
kaufmann:shipping-destination-change | Element-scoped | { destination } | ShippingSelector | composing page |
kaufmann:shipping-destination-sync | Element-scoped | { countryCode } | composing page | ShippingSelector |
// Framework-free subpath: safe in a zero-JS seam script.
import { KAUFMANN_EVENTS, dispatchKaufmann, onKaufmann } from "@kaufmann/ui/events";
dispatchKaufmann(KAUFMANN_EVENTS.collectionChange, { collectionId: "edition" });
// Returns its own unsubscribe, so an effect body is a single line.
useEffect(() => onKaufmann(KAUFMANN_EVENTS.cartUpdated, (detail) => {
setCount(detail.count);
setOpen(detail.open);
}), []);
// The shipping pair is element-scoped, not page-wide.
onKaufmann(KAUFMANN_EVENTS.shippingDestinationChange, apply, { target: root });class or className
One rule, no exceptions: Astro components take class — matching the HTML attribute Astro templates already use — and React components take className, matching JSX. So OfferCard, ProductCard, EditorialHero, EditorialCard, PricingStages, StockSignal and CartPanel take class; Button, Select, StoreHeader, CollectionFilter, ShippingSelector, Reader and the cart surfaces take className.
Every surface appends the incoming value to its own k- class rather than replacing it, so passing a class extends the component and never strips its styling.