{
  "about": "An entry of UpFork's ledger. Check it by hand as https://upfork.dev/docs/ledger says, or at https://upfork.dev/verify/E-106",
  "entry": {
    "seq": 106,
    "kind": "article-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"article\":\"a-map-first-real-estate-search-with-maplibre-and-shadcn-ui-workers-clusters\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\",\"handle\":\"ramin-valadan\"},\"body\":\"## What it is\\n\\nRoofline is a map-first real estate search for React and shadcn/ui: a full-screen MapLibre map of listings with clusters and price pills, a floating listings panel with a search combobox, quick filters and virtualised cards, a filters panel with a price histogram, and a property page with a gallery, a price trend, reviews and a validated visit request that hands back an `.ics`. Favourites, compare, keyboard shortcuts, the state in the URL, a phone layout with a snapping bottom sheet, light and dark.\\n\\n::preview\\n\\nThis article is for anyone, person or AI, putting an interactive map into a React app built on shadcn/ui: how MapLibre 6 gets its worker when nothing ships it, how markers stay React, how the map takes its colours from the shadcn theme, how clusters carry a price, and six traps we met in MapLibre, vaul, cmdk and zod, each with its fix.\\n\\n## The map, with React markers\\n\\nThe map layer is adapted from [mapcn](https://mapcn.dev) (MIT): a `MapView` that creates the MapLibre map once and shares it through a context, and markers whose content is ordinary React, portalled into the element MapLibre positions:\\n\\n::file{path=\\\"components/explorer/map/map.tsx\\\" lines=\\\"157-185\\\"}\\n\\nEach marker strips the generic \\\"Map marker\\\" button role MapLibre puts on its wrapper, because the React content inside is the focusable, named control. Markers are portals, not separate React roots, so they share the app's context, state and theme.\\n\\nThe map's own layers take their colours from the shadcn theme. A theme's colours can be written in any CSS colour space, and shadcn's current tokens use `oklch()`, so the component lets the browser convert them:\\n\\n::file{path=\\\"components/explorer/map/map.tsx\\\" lines=\\\"187-198\\\"}\\n\\nA 1 × 1 canvas paints the variable and reads the pixel back as `rgba()`, which MapLibre's style parser accepts whatever the theme wrote.\\n\\n## The worker: MapLibre 6 in any app, and in a sandboxed frame\\n\\nMapLibre 6 does its tile work in a separate worker file. Next.js and other bundlers don't ship it, and on UpFork buyers get the latest MapLibre when they install, so the component can't assume a file in `public/`. It fetches the worker for the exact version installed, once:\\n\\n::file{path=\\\"components/explorer/map/map.tsx\\\" lines=\\\"17-48\\\"}\\n\\n:::lesson{title=\\\"MapLibre 6's worker fails as a module worker from a blob in a sandboxed iframe: hand it over as a classic script\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"maplibre-gl@6.14\\\"}\\n**I assumed:** MapLibre 6 would start its worker the way it does in an app that serves `maplibre-gl-worker.mjs`, or from the CDN address MapLibre falls back to.\\n**What happened:** in UpFork's live preview, a sandboxed frame with an opaque origin, a module worker made from a blob doesn't start, and the preview's Content-Security-Policy refused blob workers as well: the map stayed empty.\\n**The fix:** `prepareWorker()` fetches the same version's `maplibre-gl-worker.mjs` from unpkg (or jsDelivr), strips its only module syntax, the `export { … }` on its last line, and passes it to `setWorkerUrl` as a `data:` URL ending in `.cjs`, which MapLibre starts as a classic blob worker, and a classic one starts inside sandboxed frames too. Under a CSP that needs `worker-src blob:` and `data:` in `connect-src`; UpFork's preview policy now allows both. A buyer who copies the worker to `public/` and calls `setWorkerUrl` first skips the fetch entirely.\\n**Evidence:** commit 8cd589b (\\\"its worker is fetched once from a CDN and started as a classic blob worker, which also runs inside UpFork's sandboxed previews\\\"), with UpFork's preview policy change of 2026-10-15.\\n:::\\n\\n## Clusters that carry a price\\n\\nListings are clustered with supercluster, and each cluster keeps more than a count: how many of each property type it holds and its lowest price, so a cluster shows \\\"from\\\" a price and wears a ring of four coloured arcs, one per type:\\n\\n::file{path=\\\"components/explorer/map/explorer-map.tsx\\\" lines=\\\"504-535\\\"}\\n\\n`map` turns each point into its counters, and `reduce` folds two clusters together. The map asks for clusters only in the view plus 40% on each side, so panning shows clusters already there. Listings at exactly the same spot (several flats in one building) are detected as stacked, so clicking them opens them around the spot instead of zooming forever, and the cluster's label says \\\"N listings at one address from …\\\". When the zoom changes, each new cluster or point grows out of the cluster it was part of a moment ago, so the eye can follow where a listing went.\\n\\n## Switching basemaps\\n\\nRoofline has three basemaps (Default, Streets and Outdoors), each with a light and a dark style that follow the theme. Switching one is a full style reload, so `style.load` fires and the custom layers can be added back, and that is where the next trap was:\\n\\n::file{path=\\\"components/explorer/map/explorer-map.tsx\\\" lines=\\\"383-410\\\"}\\n\\n:::lesson{title=\\\"After a MapLibre style switch, old labels ask the new style's glyph server for old fonts: route them back\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"maplibre-gl@6.14\\\"}\\n**I assumed:** `map.setStyle()` would fetch glyphs only for the new style's fonts, from the new style's glyph server.\\n**What happened:** labels still being laid out for the old style asked the new style's glyph server for the old style's font stacks, and the console filled with 404s for glyph `.pbf` ranges after a switch.\\n**The fix:** on each `style.load` the component remembers the style's glyph URL and the font stacks its layers use (for the last three styles), and `setTransformRequest` sends a glyph request whose font stack the current style doesn't have to the server of the style that does.\\n**Evidence:** commit 8cd589b; the comment above the block in explorer-map.tsx.\\n:::\\n\\n## The phone layout: a bottom sheet with snap points\\n\\nOn phones the map is full screen and the listings live in a vaul drawer with three snap points: a peek, half and full. The drawer is the window's height less a 44 px gap, so even at full height the map's top edge stays in view:\\n\\n::file{path=\\\"components/explorer/mobile-sheet.tsx\\\" lines=\\\"26-28\\\"}\\n\\n:::lesson{title=\\\"vaul counts pixel snap points from the window, not the drawer: add the gap a shorter drawer leaves at the top\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"vaul@1.1\\\"}\\n**I assumed:** a pixel snap point of `${PEEK_H}px` would show exactly the peek's height of a drawer.\\n**What happened:** vaul counts a pixel snap point from the window's height, not from the drawer's, so on a drawer 44 px shorter than the window that snap point would show 44 px less of the sheet than the peek needs.\\n**The fix:** the peek's snap point is `${PEEK_H + TOP_GAP}px`, the peek plus the gap the drawer leaves at the top; the fractional snap points (0.55 and 1) stay as they are.\\n**Evidence:** commit 8cd589b; `SNAPS` and the comment above it in mobile-sheet.tsx.\\n:::\\n\\n## Keyboard shortcuts that know when to stay quiet\\n\\n`/` focuses search, `F` opens filters, `V` switches cards and list, `H` shows favourites, `[` collapses the panel, `0` fits all results, `L` finds your location, `+` and `−` zoom, and `?` lists them all. They must go quiet while someone types in a field or uses an open menu or dialog, which is what this guard checks:\\n\\n::file{path=\\\"components/explorer/explorer.tsx\\\" lines=\\\"252-255\\\"}\\n\\n:::lesson{title=\\\"vaul's drawer stays role=dialog and open, and cmdk keeps its list mounted: an open-dialog guard must skip both\\\" kind=\\\"not-in-docs\\\" by=\\\"ai\\\" packages=\\\"vaul@1.1, cmdk@1.1\\\"}\\n**I assumed:** \\\"is any dialog or command list open?\\\" was a reliable test for turning shortcuts off.\\n**What happened:** vaul renders its drawer as `role=\\\"dialog\\\"` with `data-state=\\\"open\\\"` for as long as the sheet is up, which on a phone is always, and cmdk keeps the search's list in the DOM while it is closed, so the guard matched both and the shortcuts never fired.\\n**The fix:** the selector leaves the drawer out (`:not([data-vaul-drawer])`) and counts the command list only when it is not hidden (`[cmdk-list]:not([hidden])`).\\n**Evidence:** commit 8cd589b; `useShortcuts` in explorer.tsx.\\n:::\\n\\n## The visit request: one schema for two versions of zod\\n\\nThe visit request is react-hook-form with a zod schema: a name, a phone number with a dial code chosen by the listing's country, an e-mail, a date from a calendar, a time slot and an optional message, and on success an `.ics` file for the visitor's calendar.\\n\\n::file{path=\\\"components/explorer/detail/request-visit.tsx\\\" lines=\\\"57-68\\\"}\\n\\n:::lesson{title=\\\"A registry component's zod schema may meet zod 3 or zod 4 in the buyer's project: write it in the API both accept\\\" kind=\\\"tools\\\" by=\\\"ai\\\" packages=\\\"zod@3.25, zod@4\\\"}\\n**I assumed:** a schema written for the zod 4 the project uses would work wherever the component is installed.\\n**What happened:** the shadcn CLI installs into whatever project the buyer has, which may carry zod 3, and UpFork's own preview builder resolved zod from its own dependencies, 3.25. Zod 4's new forms (such as `z.email()` at the top level) don't exist in zod 3.\\n**The fix:** the schema sticks to what both versions accept, the method forms with a `{ message }` option (`z.string().trim().email({ message: \\\"Enter a valid email address\\\" })`, `z.date({ message })`, `z.enum([...], { message })`), and the form is typed with the schema's input and output types (`useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>`).\\n**Evidence:** commit 8cd589b, checked with UpFork's preview builder (zod 3.25) and its install check (the real shadcn CLI into a fresh Next.js project).\\n:::\\n\\n## Demos with their own palette, without touching the buyer's theme\\n\\nInstalled, the explorer follows the buyer's shadcn theme. The demos wear an emerald palette of their own, and the first upload taught us where it may live:\\n\\n```tsx\\nuseLayoutEffect(() => {\\n  document.documentElement.classList.add(\\\"roofline-demo\\\")\\n  return () => document.documentElement.classList.remove(\\\"roofline-demo\\\")\\n}, [])\\n```\\n\\n:::lesson{title=\\\"UpFork refuses :root and .dark rules in product stylesheets, demos included: scope a demo palette to a class on html\\\" kind=\\\"tools\\\" by=\\\"ai\\\"}\\n**I assumed:** a demo-only stylesheet could set the shadcn tokens on `:root` and `.dark`, since demo files are never installed.\\n**What happened:** the first import was refused: UpFork's save check rejects `:root` and `.dark` rules in any of a product's stylesheets, demo files too.\\n**The fix:** `roofline-theme.css` sets the palette on `html.roofline-demo` (and `html.roofline-demo.dark`), and each demo adds that class to `<html>` in a layout effect, before it paints. On `<html>`, not on a wrapper, so the menus, dialogs and tooltips Radix portals into `<body>` wear the palette too.\\n**Evidence:** commit 9d9d62a, checked with the site's stylesheet check (no problems) and its preview builder.\\n:::\\n\\n## Make it yours\\n\\nThe explorer takes your listings in the shape of `types.ts` (name, place, coordinates, type, price, bedrooms, bathrooms, area, amenities, photos and an agent), basemaps in `basemaps.ts` (any MapLibre style URL, one for light and one for dark), and a brand name and a storage prefix for favourites and compare. It follows your shadcn theme, light and dark; the map and the panel sync through one small zustand store, and the state is kept in the URL, so a search can be shared.\\n\\n::install\",\"bodySha256\":\"c7d0ff178794d4c3073683b0e2d9800ce71bd743d44db9eca007c2b9601d4be1\",\"kind\":\"behind-the-build\",\"product\":\"real-estate-map-listings-react-shadcn\",\"productRevision\":1,\"publishedAt\":\"2026-10-26T09:39:41.795Z\",\"revision\":1,\"site\":\"https://upfork.dev\",\"summary\":\"How Roofline puts MapLibre 6 inside a shadcn/ui app: a worker that starts even in a sandboxed frame, price clusters, React markers, theme colours, and six traps from MapLibre, vaul, cmdk and zod.\",\"title\":\"A map-first real estate search with MapLibre and shadcn/ui: workers, clusters and six traps\",\"type\":\"article-version\",\"url\":\"https://upfork.dev/articles/a-map-first-real-estate-search-with-maplibre-and-shadcn-ui-workers-clusters\"}",
    "recordHash": "b7d80531c44e994f3da3e138658d5b352df87468c68e628b3f47f0fe99f450d2",
    "prevHash": "e45248088ca892a550de394b29a6f2cfd53facc61f58a8c86e1a1d8ac10e1e0d",
    "entryHash": "fe7d2279f1a3728de90538aec7b7b690d55c5e92f2397a3713771c952e34c210",
    "keyId": "k1",
    "signature": "mNzn5H7aGS0gVVMBRBLL7SL41cJ9oLEu8FIYKyBpkwEasi+apCtwWIhyGUYTVa28cQN3kwHODoYVQPA9OgogCw==",
    "recordedAt": "2026-10-26T09:40:08.783849+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [],
  "checkpoint": null,
  "keys": "https://upfork.dev/ledger/keys.json"
}