# Build a find-a-dealer map you own A method for building a dealer locator on your own store — no monthly app, no third party holding your dealer list. Point an assistant at this file and it can run the method with you. Source: https://jenso.ai/resources/find-a-dealer-map This file: https://jenso.ai/resources/find-a-dealer-map/llms.txt Publisher: Jenso LLC (jenso.ai) Licence: free to read, quote and use, with attribution. ## The problem this solves A brand with physical resellers wants to send hesitant online shoppers into a showroom. Every off-the-shelf answer is a paid app at $10–70/month that owns your dealer data in its own database. The native alternative — structured records plus a template plus a self-hosted map — is achievable but has roughly a dozen silent failure modes, most of which produce a page that looks fine and is wrong. ## The uncomfortable first finding **The feature was not blocked by the map. It was blocked by the data.** On the reference build, 4 separate lists each claimed to be "the dealer list" and only 6 names appeared on more than one. Three of the four confirmed demo locations were absent from the spreadsheet everyone treated as authoritative. The list that was easiest to export was a dropship channel — the retailer lists the product, the order routes to the supplier, and the product may never physically exist at the retailer's address. A locator built from that list sends customers to touch something that is not there, which is worse than having no locator. **The gate before anything else:** do not design the presentation until you can answer "which of these records describes a place a customer can stand in, with the product in it". If the answer is "we would have to ask them", that is the project, and the map is the easy part that comes after. ## The four layers 1. **Roster of record** (human-owned) — A plain markdown table. One row per location, a last-verified date, and provenance on every disputed field. Not the spreadsheet, not the CRM. ↓ generate — never transcribe 2. **Seed data** (generated) — Emitted from the roster by a script, with refusal gates. Carries the projected pin coordinates and the projection version that produced them. ↓ upsert, keyed on the record’s own identity 3. **Platform records** (platform) — Where the store keeps structured content. Survives a theme swap, which is why the expensive layer lives here. ↓ rendered by 4. **Presentation** (disposable) — The section, the stylesheet, the map geometry. Version-controlled, re-pushable, and safe to throw away. The direction of flow is one-way. Corrections go into layer 1 and regenerate downward. **Editing layer 2 or 3 directly is how the roster and the live site drift apart** — and the drift is invisible, because both look fine in isolation. ## The method — eleven stations Each station has a gate: the condition that must be true before the next one starts. Station 7 is where the time actually goes — on the reference build it cost more than the map, the schema and the deploy combined — and it cannot be delegated to a model. ### Station 1 — Establish the tier vocabulary before collecting data A locator makes a promise about what happens when somebody arrives. Different locations keep different promises. Who does it: owner The instinct is one flag: "is this a dealer?" That collapses under contact with reality, because a pin is a **promise** that there is a door, and behind some of those doors there is nothing to touch. **The rule that generalises:** a tier is defined by _what a visitor can actually do at that address_ — not by contract type, revenue, or how you file them internally. Ask that question about your own network and you will usually land on three to five tiers. Write them down before collecting a single record, because the answer determines which questions you have to ask each location. Whatever tiers you choose, resist collapsing the underlying facts into one status field. On the reference build the owner supplied, over the course of an hour, all four combinations of two flags: storefront and demo unit and stock; storefront and demo unit but no stock; storefront with neither; and no storefront at all. So those two are **independent booleans**, and a single status enum would have forced a lie on half the roster. WORKED EXAMPLE, NOT A TEMPLATE. The reference build sells bulky mechanical hardware that bolts to a vehicle, so _is there one on the floor_ is the axis that matters. Yours will be a different axis. What transfers is the question, not the answer. - **demo** (Primary pin) — Walk in and put hands on a mounted unit - **reseller** (Secondary pin) — A real location that sells it — no demo guaranteed - **pickup** (Distinct marker) — The brand's own collection point. Not a showroom. - **online** (No pin) — No storefront. A pin is a promise there's a door. **Never infer a tier from sales volume** — On the reference build the single largest account by revenue — over a third of channel sales — has no storefront and no inventory. It correctly gets no pin. Revenue tells you who sells; a locator is about who can be visited, and those are different questions with different answers. **Your criteria — decide this for your own catalogue.** List the binary facts that change what you promise a visitor — each one its own boolean field, never a new tier. Two or three is typical. Ones that came up here or are obvious neighbours: has a unit on display · holds stock · installs on site · appointment only · trained staff · carries the full range · rents as well as sells. **Gate — all of these must be true before the next station:** - Three to five tiers, each implying a different action for the visitor - If two tiers would produce the same card, they are one tier - Every binary fact that varies within a tier is its own boolean field - One yes/no per flag, per location, answered by a human **What you do here:** Interview the owner about what a visitor can do at each kind of location. Draft the tier list and the flag list from their answers, and stop there. **What you must ask the human:** - What can a customer actually do at each kind of location — look, touch, buy, collect, book? - Which of these facts vary between two locations of the same tier? - For each location: one yes/no per flag. Nothing else resolves these. **What you must never do:** - Infer a tier from sales volume, contract type, or how the business files them internally. - Collapse two independent facts into one status enum because it reads more cleanly. - Give a pin to a location with no storefront. A pin is a promise there is a door. - Invent a tier the owner did not describe. ### Station 2 — Research your platform's limits before choosing an architecture A hosting platform imposes a hard ceiling somewhere you did not expect. The cheapest time to find it is before the architecture is set. Who does it: research The limits in the table are one platform’s. Every platform has an equivalent set. The transferable lesson is not the numbers — it is that the numbers exist, that at least one of them will decide your architecture, and that the one which bites is usually the one that fails _silently_. The row cap deserves emphasis because it is the quiet one. Nothing errors when you exceed it. The page simply stops showing the rest of your data, and it renders perfectly while doing so. The same store had an existing section iterating 135 entries with no pagination wrapper; roughly **85 rows had been invisible for months** and nobody had noticed. **The parent→children shape is the one that will not scale.** A locator reads naturally as region → locations. On a platform with a row cap, resist modelling it that way: measured on the reference platform with a 135-child parent, the pagination escape hatch lifted a _global collection_ from 50 to 135 rows, and did nothing at all for a parent’s reference list. Items past the cap there are not merely unrendered, they are unreachable — you cannot skip to them. So iterate the flat global collection and group in the template, even though grouping by parent is the more natural data model. **And never validate coverage with a reported count.** The list’s own `.size` returns 135 while the loop yields 50, so a size-based check passes on a truncated render. Count what actually rendered. One platform's numbers. Yours will differ; that a hard ceiling exists somewhere unexpected will not. - **Template file size: 256 KB** — Map geometry cannot live in the section. It ships as a separate asset. - **Records per loop: 50 by default** — Silent truncation. A pagination wrapper raises it to 250 per page. - **Fields per record type: 40** — Budget them. The reference build used 24. - **JSON field write: 128 KB** — Geometry cannot be stuffed into a field value either. - **Record type description: 255 characters** — Creation fails outright. This one was undocumented in the guides we had. (found by hitting it, not documented) Measured, with a 135-child parent: - Global collection, plain loop → 50 - Global collection, paginated → 135 - Parent's child-reference list, plain → 50 - Parent's child-reference list, paginated → 50 - Skipping past the cap in a list → 0 — genuinely unreachable - The list's own reported size → 135 The last two are the finding: a size-based check reports the true count while the loop yields the truncated one, so it passes on a truncated render. ``` Roughly half of any locator's schema is the same everywhere: business_name short_name (map label) tier street_address city state / region postal_code latitude longitude map_x map_y (derived — see station 5) phone email website_url contact_url hours last_verified card_note internal_note (never rendered) ``` **Two fields earn their place whatever you sell** — A per-record last-verified date, because stale data is the number-one documented locator failure and it decays silently. And an internal note — who to call, why a record is disputed — which is genuinely useful operational memory and must never reach the storefront. Store it, then assert in the validator that it never renders. **Your criteria — decide this for your own catalogue.** Find your platform’s row cap **and** find out whether its escape hatch applies to every access pattern or only some. Those are two separate questions and the second one is the expensive one to get wrong. Then budget your fields: the open half of the schema is one boolean per flag from station 1, plus whatever a visitor to your category needs before driving. **Gate — all of these must be true before the next station:** - The row cap is known, and so is whether the escape hatch covers every access pattern - The pagination wrapper is in place on day one, while it is still a no-op - Coverage is checked by counting rendered output, never by a reported size - Field budget agreed, with a last-verified date and an internal note on every record **What you do here:** Read the platform’s own documentation for size, iteration and field limits. Report each with the source you found it in, and flag which ones the proposed architecture would hit. **What you must ask the human:** - Which platform is this shipping on, and which plan? - Is there an existing page on this store that iterates records? It may already be truncating. **What you must never do:** - Assume a limit from memory. Quote it from the platform’s own docs, or measure it. - Report a limit as found without saying whether you read it or hit it. - Use array filters on the platform’s record objects without checking they support field access — they frequently return nothing and never error. - Treat a reported count as evidence that every row rendered. ### Station 3 — Build the HTML file first, whatever your stack One self-contained file, generated by a script, with real data. Not a mockup — the actual renderer. Who does it: mechanical On any stack this is the fastest path. On a hosted template platform it is also the only sane one, because the alternative is a push and a cache wait per iteration — and the page you are staring at may not be the page you just shipped. It is not a mockup. It is the actual renderer, the actual interaction code and the actual data, and on most stacks it is also the finished artifact. What it buys is iteration in seconds, design decisions made against _real_ records, and cheap failures: the reference prototype went through more than twenty revisions with the owner, and three scaling bugs, a projection bug and a broken pan interaction were all found and fixed before any platform code existed. Four layers inside the one file, each independently swappable — brand tokens, an optional motif, the map, and one array of locations. Nothing above depends on a framework, a bundler, an API key or a tile server, and there is no runtime request to anyone. ``` ┌─ Brand layer CSS custom properties. Point them at your brand guide. ├─ Motif layer Optional decorative geometry. ├─ Map layer Inline SVG. Country outline + your regions. └─ Data layer One JSON array of locations. ``` ``` .your-component { /* Lands on ~16px against a host whose root is 10.5px. On a host with the usual 16px root, set this to 1rem. */ --base: 1.524rem; } .your-component__card-name { font-size: calc(var(--base) * 1.28); } ``` **The unit did not mean what the stylesheet assumed** — The artifact drops into somebody else's site, and that site sets the root font size. The reference theme sets 10.5px, so every rem written against the usual 16px assumption rendered at about two thirds of its intended size — the component's headings came out smaller than the host's paragraphs. Nothing errors, code review passes, and it surfaces as "the text feels small", which reads like a taste problem and gets answered with a taste fix. That works slightly, which is the worst outcome, because it confirms the wrong diagnosis. Measure the host's root before you write a single size. **Your criteria — decide this for your own catalogue.** The motif layer is the most brand-specific thing on the page, and it should be yours or nothing. The mechanism generalises even when the imagery does not: define it as coordinates in the _real world_, project it with the same projection as the map, render it under the data layer clipped to the landmass, and scale it **down** as the reader zooms in — texture should recede as data comes forward. If it competes with the pins for attention, delete it. **Gate — all of these must be true before the next station:** - It opens in a browser and works, with no build step and no dependencies - Every colour is a namespaced CSS custom property, not a hex in the markup - Density is carried by marker shape as well as colour, so it survives a monochrome print - Type is anchored to a base the component owns, never to the host’s root - The human approves it before any platform code is written **What you do here:** Generate the standalone file from the roster with a script. Measure the host’s root font size first and set the component base from it. **What you must ask the human:** - What are your brand’s accent, surface, text and line colours? - Is there a motif that comes from the product’s context, or should we ship without one? - Which host site will this be dropped into, so the root font size can be measured? **What you must never do:** - Write a size before measuring the host’s root. The unit may not mean what you think. - Use bare custom property names. Namespace every token and class, or you will collide with the host. - Signal density with colour alone. - Proceed to platform work before the human has approved the artifact. ### Station 4 — Decide where each thing lives, on durability not convenience Hand-edited theme code can be lost on a template update. The records you gathered by asking humans are the part that must survive. Who does it: owner + developer Ask one question per artifact: what happens to this on a platform or theme update? On the reference platform the answer is that code edits carry forward only if they do not conflict, there is no merge tool, and the standing advice is to keep your own copy. **The expensive part — the records you gathered by asking humans — is the part that is already safe**, because structured records are stored at the shop level and are theme-independent. The presentation layer is code you can re-push. That asymmetry is the whole reason for putting the roster in records rather than in the template. There is usually a fully update-proof option, and it usually costs more than it looks: on the reference platform it is an app extension, which needs a developer account and the standard app architecture with a hosted backend, because the no-backend template does not support it. **Your criteria — decide this for your own catalogue.** Is this long-lived infrastructure, or a feature you are shipping now? If the store already carries several custom sections, the durability question is store-wide and should not be answered for one page in isolation. **Gate — all of these must be true before the next station:** - Every artifact is placed by what survives an update, not by what is quickest - The records survive a theme or template swap - Anything that does not survive is version-controlled and re-pushable from a script **What you do here:** List every artifact the build produces and state, for each, whether it survives a platform update and what regenerates it if not. **What you must ask the human:** - Is this page long-lived infrastructure or a feature we are shipping now? - Does the store already have custom sections whose durability was decided some other way? **What you must never do:** - Put the human-gathered records anywhere that a template update can remove. - Recommend the fully durable option without stating what it actually costs to stand up. ### Station 5 — Pin the projection in exactly one place Stored pin coordinates are only correct relative to the geometry they were computed against. That coupling is invisible and total. Who does it: mechanical If the map is a static SVG rather than a tile service, geographic coordinates have to become pixel coordinates. Most template languages cannot do that arithmetic, so it happens once, at seed time, and the result is stored per record. Which creates the coupling: change the projection scale when regenerating the map, and **every pin drifts off its region** — with no error, no warning, and a page that looks entirely plausible. The mitigation is one module exporting the constants and a version string, imported by the geometry builder, the coordinate projector and the verifier. The verifier fails if any record’s stamp does not match the asset’s. ``` projection.(js|ts) <- scale, translate, version. One file. ├─ build-map-geometry ├─ project-locations └─ validate ``` **Never hand-edit a stored coordinate** — If a pin is in the wrong place, the address or the latitude is wrong. Fix that and regenerate. A hand-corrected pixel value is correct until the next regeneration silently overwrites it, or worse, survives one and disagrees with every other record. **Gate — all of these must be true before the next station:** - Scale, translate and a version string are written down in exactly one file - The geometry builder and the coordinate projector both import it - The verifier fails when a record’s projection stamp does not match the asset’s - No coordinate anywhere was typed by a person **What you do here:** Create the single projection module, then import it everywhere a coordinate is produced or checked. Add the version stamp to every generated record. **What you must ask the human:** - Which projection and viewBox does the approved artifact use? **What you must never do:** - Hardcode a scale or translate anywhere except the projection module. - Hand-edit a stored pixel coordinate for any reason. - Regenerate geometry without re-projecting every record against it. ### Station 6 — Generate the seed data, never transcribe it A wrong phone number is worse than no phone number, because the customer concludes the whole page is stale. Who does it: mechanical The approved artifact already contains the records — street addresses, phone numbers, email addresses. Retyping those into a seed file is where a transposed digit enters, and it enters silently. So the extractor reads the approved artifact and emits the seed module, with refusal gates: a malformed email, coordinates outside the country’s envelope, an unknown tier, a duplicate name, or a record still flagged placeholder all **abort the run** rather than emitting a partial roster. Then cross-check. Every projected coordinate was compared against the artifact’s own rendered output: 20 of 20 exact. That check is cheap, and it is the difference between believing the pipeline is faithful and knowing it. **Gate — all of these must be true before the next station:** - Zero hand-typed coordinates, addresses or phone numbers - The extractor refuses the whole run rather than emitting a partial roster - Every projected coordinate matches the approved artifact’s rendering **What you do here:** Write the extractor. Validate the whole set, then emit. Report the coordinate cross-check as a count, not as a claim. **What you must ask the human:** - Which artifact is the approved one? Anything not approved is not a source. **What you must never do:** - Transcribe a record by hand, however few there are. - Emit a partial roster when one record fails validation. - Report the cross-check as passing without stating how many matched out of how many. ### Station 7 — Verify the contact data — this is the actual project It cost more time than the map, the schema and the deploy combined. The map is the easy part that comes after. Who does it: the constraint — a human, permanently **The feature was not blocked by the map. It was blocked by the data.** On the reference build 4 separate lists each claimed to be "the dealer list", and only 6 names appeared on more than one. Three of the four confirmed demo locations were absent from the spreadsheet everyone treated as authoritative. The deeper problem was categorical. A dropship relationship — the retailer lists the product, the order routes to the supplier — means the product may never physically exist at the retailer’s address. A locator built from that list sends customers to touch something that is not there, which is _worse_ than having no locator, because it burns the customer and the dealer at once. **Addresses drift in five distinct ways**, all observed on twenty real businesses. A site contradicts itself, and the policy or contact-information page set once in settings is the most reliably stale field on the platform. The search engine’s own record is stale too, so first-party-versus-search is not a tiebreak. A directory points at the warehouse rather than the showroom, and customers arrive at a loading dock. Expansion looks identical to relocation — always ask "is this a move or an addition". And ownership changes lag everywhere: one business was acquired, its own site never mentioned it, its social page title already carried the new city, and trade press had the deal date. **Email is where automation gets confidently wrong.** Search snippets produced two wrong addresses on the reference build — one pointing at a sibling brand’s near-identical domain, one a generic address that exists nowhere on the company’s site. Both would have bounced. Only ever take an address off a page you actually rendered. And check MX records, not A records: one business publishes mail on a domain that serves no website at all and delivers fine, while the domain that _does_ serve their site has no MX record. An automated liveness check got both backwards. Ranked by how often each was right _when sources disagreed_ — which is the only ranking that means anything, since they agree on the easy records. 1. **The human who owns the relationship** — Overruled web research on five records and was right every time. The internet lags relationships someone manages directly. 2. **The company's own site — read several pages** — Their pages contradict each other. The policy page set once in settings is the most reliably stale field on the platform. 3. **The company's own DATED social posts** — "Come see our new store at X" beats any static page. The date is the whole value. 4. **Government records** — A city sign permit dated a relocation to the month. Nobody pulls a wall-sign permit for a building they are leaving. 5. **Search-engine mirrors** — Best available proxy when the map service blocks automated fetch — and it was stale too. 6. **Generic directories** — Frequently a previous address. One listed a warehouse rather than the showroom. 7. **Search-result snippets** — Actively produced wrong data twice, including an address at a near-identical sibling domain. ``` Ranked by how often each was right when sources disagreed: 1 the human who owns the relationship overruled research 5/5 times 2 the company's own site, several pages their pages contradict each other 3 their own DATED social posts dates are the whole value 4 government records a sign permit dated a move to the month 5 search-engine mirrors best available proxy — and stale too 6 generic directories frequently a previous address 7 search-result snippets produced wrong data twice ``` **Park what you do not understand — never delete it quietly** — A QA pass cut an unexplained fragment from an hours field — five characters nobody could read — and flagged it to ask about. It meant pickup lockers: you can collect an order well outside the shop’s staffed hours, which for a customer on a remote island is one of the most useful facts on the page. A fragment you cannot parse is usually compressed knowledge, not noise. Cutting it and raising it in the same breath cost one round trip and recovered a genuine selling point; cutting it silently would have lost it permanently, and there is no error message for a fact that stopped existing. **Your criteria — decide this for your own catalogue.** When a record carries a fact that does not fit any field you defined, that is a signal your schema is short a field — not that the fact should be crammed into the nearest one. And pick your staleness window from how fast your sector actually moves: twelve months is a reasonable default, six is defensible. **Gate — all of these must be true before the next station:** - Every location traceable to a source you would defend out loud - Nothing sourced only from a search-result snippet - Every conflict resolved by the human who owns the relationship - "Did they move or did they add" asked explicitly, per conflict - Written permission to list each business, which doubles as an accuracy check **What you do here:** Research each location against the hierarchy, render the pages you cite, and report conflicts with their sources ranked. Hand every conflict to the human rather than resolving it. **What you must ask the human:** - Which of these records describes a place a customer can stand in, with the product in it? - For each conflict: is this a move or an addition? - Have they agreed to be listed? Publishing an address and routing customers there should be opt-in. **What you must never do:** - Construct an email address from a pattern. If a business does not publish one, do not invent it. - Take an address from a search snippet rather than a page you rendered. - Infer that a business is trading from a live-looking search result. - Conflate website liveness with email liveness. Check MX, not A. - Quietly delete a fragment you cannot parse. Flag it and ask. - Return lower-confidence data without saying that a fraction of verification was manual. ### Station 8 — Write a validator for the things that fail silently Ordinary bugs announce themselves. Each of these produces a page a reviewer would sign off on. Who does it: mechanical A missing pagination wrapper truncates at the row cap and renders perfectly. An array filter over record objects matches nothing and never errors. A boolean read without its value accessor is truthy for the field _object_ regardless of what is stored, so every record reads "in stock". A missing storefront-access grant renders zero rows while the admin looks healthy. Records left in draft are skipped entirely, so a successful seed produces an empty page. And an internal field leaks to the storefront with nothing to stop it. **One of them takes the whole page down at once.** The map payload is JSON inside a script element, and most template `json` filters escape quotes and backslashes but _not_ forward slashes. A value containing the literal sequence that closes a script tag ends the block early; the browser parses the rest as HTML, the payload never reaches the map, and every pin disappears — not just that record’s. No linter catches it, because the template is correct. It only fires when the data happens to contain the sequence, which is why it survives every review and then appears eighteen months later when somebody imports a record from a bad CSV. Two defences, and use both: reject angle brackets at the seed gate, since no real business name, street address or phone number contains one; and strip markup before serialising, as a second line of defence for anything already stored or edited later in an admin UI. **Test the validator, not just the code** — A validator that has never failed is not evidence of anything. Make deliberately broken copies and confirm each is caught. On the reference build that took twenty minutes and 2 of the 4 broken copies PASSED — which is how the script-termination hole was found, and how a missing boolean assertion was found. Both were real defects in code that had already been reviewed. One warning from doing it: the first mutation deleted a phrase from the file's own explanatory comment rather than the actual tag, and reported a pass that meant nothing. Assert that your mutation changed what you think it changed before trusting its result. **Your criteria — decide this for your own catalogue.** If your records legitimately contain markup — a rich-text description, say — you cannot strip it. Serialize into a data attribute with HTML escaping instead and read it with _getAttribute_. Attribute context has no early-termination problem. **Gate — all of these must be true before the next station:** - Every silent failure above has an assertion - The validator fails on a deliberately broken copy of the template - Each mutation is confirmed to have changed what it claims to have changed **What you do here:** Write the build-time validator, then mutation-test it. Report which mutants survived — a surviving mutant is a finding, not a footnote. **What you must ask the human:** - Do any records legitimately contain markup? That changes how the payload must be serialized. **What you must never do:** - Report a validator as working without running it against a broken copy. - Trust a mutation you have not confirmed landed. - Assert on the template source instead of the rendered output. ### Station 9 — Render the template locally, before you push anything The last station added, and the one that would have saved the most time if it had been the first. Who does it: mechanical The default loop for a hosted template language is push, wait, look, guess — and with a page cache serving a stale render for half an hour afterwards, the page you are staring at may not be the page you just shipped. You end up debugging a ghost. The fix is to render the same template file on your laptop, against the same data, in about a second. Three properties make it worth building rather than eyeballing. **It fails loudly on anything unshimmed** — turn on strict filters, so an unknown platform filter throws instead of quietly rendering nothing, which would look like a pass. **It can simulate the platform’s limits**, turning an invisible production ceiling into a failing assertion on a laptop. And **it can lie about scale**: at twenty records the pagination wrapper is decoration, so a stress flag that clones the roster makes it load-bearing and removing it fails locally instead of in eighteen months. Test at the size you will be, not the size you are. Assert on the rendered output, not the template source. Source linting checks the spelling you thought of; output assertions check the result however it was spelled. ``` Assert on OUTPUT: card count == record count (at --stress size, not just today's) the JSON payload parses the structured data parses every coordinate lands inside the viewBox no internal-only field text appears anywhere in the HTML every mailto: resolves to an address that exists in the data boolean-driven copy appears exactly as often as the boolean is true ``` **Your harness is not the engine that ships** — Every local emulation diverges from production somewhere; the only question is whether you find out on purpose. The reference platform’s template engine does not process backslash escapes inside string literals and the JavaScript implementation used by the harness does — so one fix was working in one engine and a silent no-op in the other, and the test output could not tell you which, because a failure looks identical to a fix that did not apply. Prefer constructs that behave identically in both engines, lint for the divergence itself, and keep a written list of what the harness cannot tell you. **Gate — all of these must be true before the next station:** - The template renders locally against the fixture, and against LIVE records before any deploy - The harness throws on an unshimmed platform filter rather than rendering nothing - Assertions pass at stress size, not just at today’s record count - A written list exists of what the harness cannot tell you **What you do here:** Build the local render harness with strict filters on. Run every assertion against the fixture, then against records pulled live from the store. **What you must ask the human:** - Are records edited by non-developers? If so, rendering against live data is recurring, not a pre-deploy step. **What you must never do:** - Report a pass from a harness that silently ignores an unknown filter. - Test only against the fixture file. A harness that only sees your fixture tests your fixture. - Assume the local engine and the shipping engine agree on string handling. - Debug the code you just changed before confirming the thing you are testing with still works. ### Station 10 — Small-scale test before batch Dry run, commit, one record, render, then the rest — with a gate at every step. Who does it: mechanical Records are independent of the template, so seed before you deploy: the first deploy then renders a full page instead of one lonely record, and a layout problem that only appears at twenty rows shows up on the first look rather than the second. **Make seeding idempotent and a failed batch costs nothing.** Derive each record’s key from its own identity — a slug of the business name — rather than letting the platform mint one. Writing becomes upsert, so re-running updates in place instead of creating duplicates. That single property is what makes it safe to fix one bad record and re-run the whole batch, which is what you will actually want to do, repeatedly. **Validate the whole batch before writing any of it**, not per-record as you go. A record that fails on write leaves the roster half-seeded, and now you are reasoning about a partially-applied change. And make the dry run work _without credentials_ — a dry run writes nothing, so requiring a token puts the most valuable part of the script, the pre-flight validation, behind a secret. The reference build got this wrong initially: the validation gate could not run on a machine that had not unlocked the password manager, which is exactly the machine a second person reviews from. - Definition script **dry run**. Inspect the payload. Commit nothing. - Commit the definition. Then **read it back** and confirm public read access and publishable status before seeding anything. - Seed **one** record. Read it back from the API and confirm it is active, not draft. - Render locally against that one live record. This answers most of what a deploy would have told you, at no cost and with no cache. - Batch the rest. Re-run the verifier. Render locally again, against all of them, from the live store. - Deploy to an **unpublished** environment. Confirm a pin lands correctly. **Verify a scoped deploy against its source, not your working copy** — When you deploy part of a project into an existing site, the check that matters is whether anything else moved — and the tooling's "success" is not evidence. The intuitive check, diffing the deployed target against your working copy, is the wrong comparison: a working copy carries unrelated in-progress work, so a diff cannot separate "the deploy was correctly scoped" from "my whole working copy just went up". Diff the target against the thing it was cloned from. On the reference build that was 0 differing files, with the extras being precisely the ones deployed. That is a provable statement. **Your criteria — decide this for your own catalogue.** Expect the admin UI’s pickers to be scoped to what is currently _published_, not to the draft environment you are working in — so the thing you just built is simply not in the list and it looks like it failed to deploy. The underlying value is usually just a string on the record, and the API will set it regardless. Before working around it, look for accidental evidence: something in your system is often already doing the risky thing quietly, and has been for long enough to count as a result. **Gate — all of these must be true before the next station:** - Every write read back from the API, never inferred from the rendered page - The dry run works on a machine with no credentials - The whole batch validates before a single record is written - Seeding is idempotent on a key derived from the record’s own identity - A scoped deploy diffed against its source, not against your working copy **What you do here:** Run each step in order and report the read-back for every write. Stop at the first gate that does not pass. **What you must ask the human:** - Which environment is the unpublished one, and what was it cloned from? **What you must never do:** - Trust a rendered page as evidence of a successful write. Caches serve stale renders for a long time. - Write any record before the whole batch has validated. - Let the platform mint a record key when the record has its own identity. - Require credentials for a dry run. ### Station 11 — The audit that keeps running after you stop looking Almost every way this page goes bad after launch involves no code change at all. Who does it: recurring Station 8’s validator is a build step: it runs when you change the code. This is a different artifact with a different job. Someone flips a record to draft in the admin and it vanishes from the page with no error raised anywhere. Someone edits a record in the admin instead of the source of truth, and the next deploy silently reverts their work. Storefront access gets downgraded and the _entire page_ empties while the admin looks perfectly healthy. A dealer moves, closes or gets acquired and nobody tells you. **Write it read-only.** It must be safe to run against production at any time, by anyone, without thinking about it. The moment an audit can write, running it becomes a decision — and a check you have to decide about is a check you skip. **Separate blocking from advisory.** A record in draft is a failure; a location last verified fourteen months ago is a warning. Collapsing those into one severity means either the audit blocks constantly or it stops meaning anything. ``` Read-only, and it asserts: every record published, not draft the default failure mode of API writes public read access still granted empties the whole page, silently every required field populated a blank name renders a nameless card derived values agree with their source catches hand-edits and reprojections store matches the repo, field by field catches admin edits and pending deploys no two records on the same pixel one pin hides another tier and flags not contradictory a demo tier with no demo unit is a typo every contact route well-formed a malformed link is invisible until clicked nothing older than your window the station 7 problem, made visible record count against the row cap warn early; the failure past it is silent ``` **A checker that cries wolf stops being a checker** — The first live run reported one error and one warning and both were wrong, with the same root cause: three scripts each keeping their own idea of what a field is. The audit assumed every key on a source record was a stored field, and reported a missing field that was never meant to exist. Blank optional values are omitted on write rather than stored as empty strings, so the store holds null where the source holds an empty string — read naively, that is drift on every optional field every dealer left blank. Neither was a real defect, and that is exactly what makes it dangerous: an audit that reports noise on a clean system teaches you to skim its output, and then it is still running but no longer doing anything. **Your criteria — decide this for your own catalogue.** Find your platform’s coercions before you trust a diff. A decimal field given _321_ comes back as _321.0_, so eighteen of twenty records round-tripped byte-identically and the two that landed on whole numbers looked like drift. Compare numbers numerically, never as text. Common ones: decimals gaining trailing zeros, booleans stored as a string or an integer, dates normalised to UTC, and text silently trimmed. **Gate — all of these must be true before the next station:** - The audit is read-only and safe for anyone to run against production - Blocking and advisory findings are separate severities - The field list, the required list and the empty-value rule live in ONE module every script imports - It compares what a write WOULD produce against what is stored, not raw record against raw record - It runs clean, so a non-clean run means something **What you do here:** Write the read-only audit against the shared field module. Run it, then fix every false positive before anyone is asked to read its output. **What you must ask the human:** - What is the staleness window for this sector? - Who receives this when it fails, and have they agreed to read it? **What you must never do:** - Give the audit any ability to write. - Ship it while it still reports a false positive on a clean system. - Compare a stored value to a source value as text. - Collapse "broken right now" and "somebody should look" into one severity. ## Why the gates exist Every rule above came from something breaking, and what the failures have in common is that each produced a page a reviewer would have signed off on. ### Eighty-five rows had been invisible for months, on a page that rendered perfectly An existing section on the same store iterated 135 records with no pagination wrapper. The platform returns 50 and stops. Nothing errors, nothing warns, and the page looks complete — so roughly 85 rows had simply not existed for anyone visiting, and nobody had noticed. The trap underneath it is worse than the cap. The list’s own reported size returns the _true_ count while the loop yields the truncated one, so any check written against a count passes on a truncated render. Coverage has to be measured on what rendered. **The rule this produced (Station 2):** Wrap the loop on day one, at twenty records, while it is still a no-op — nobody remembers at record 51. And count rendered output, never a reported size. ### One record’s name could take every pin on the page down The map payload is JSON inside a script element, and the platform’s serializer escapes quotes and backslashes but not forward slashes. A value containing the sequence that closes a script tag ends the block early: the browser parses the rest of the JSON as HTML, the payload never reaches the map, and every marker disappears — not just that record’s. No linter catches it, because the template is correct. It fires only when the _data_ happens to contain the sequence, which is why it survives every review and then surfaces eighteen months later when somebody adds a record from a bad CSV import. It is not platform-specific either: the same hole exists in React with a serialized payload set as raw HTML. **The rule this produced (Station 8):** Reject angle brackets at the seed gate, and strip markup before serialising. Use both — one covers what you write, the other covers what somebody edits in an admin UI later. ### Two of four deliberately broken copies passed the validator Twenty minutes of mutation testing on a validator that had already been reviewed: delete the pagination wrapper, leak an internal field into a card, drop a boolean accessor, remove an escape. 2 of 4 passed. Both were real defects, and one of them was the script-termination hole above. The first attempt at the mutation was itself wrong — it deleted the phrase from the file’s own explanatory comment rather than the actual tag, and reported a pass that meant nothing. A test harness can be wrong in exactly the same way the code can. **The rule this produced (Station 8):** A validator that has never failed is not evidence of anything. Break it on purpose — and assert that your mutation changed what you think it changed before trusting the result. ### Four lists, and they disagreed in both directions The CRM's record type had no address, phone or coordinate fields at all. The sales-channel export had revenue and no addresses, because that channel is dropship and never needs one. The signed-agreements folder was accurate and stale. The rest was in the owner's head. 4 lists, 6 names in common — and three of the four confirmed demo locations were missing from the spreadsheet everyone treated as authoritative. ### The address that is not a place One "dealer" published exactly one address anywhere: a registered-agent mail drop shared by several company-formation services. Nobody works there, and a pin would have landed on a mailbox service in a state the business has no presence in. Watch for suite numbers in buildings hosting many unrelated companies, incorporation-friendly states for a business that operates elsewhere, and virtual-office language on the building’s own listing. ### Website liveness and email liveness are unrelated facts One business publishes mail on a domain that serves no website at all — and it has a valid MX record, so mail delivers fine. An automated "is this domain alive" check called it dead and would have stripped a working address. The reverse was also true: the domain that _does_ serve their website has no MX record, so mail there would bounce. That is precisely why they publish the other one. ### Check your instruments before you doubt the aircraft The map stopped reframing after a search. The code was new, so the code was the suspect — twenty minutes of reading it and reproducing the arithmetic by hand confirmed it computed exactly the right answer, which only deepened the confusion. What broke the deadlock was testing something unrelated: a zoom verified working earlier the same day also did nothing. Two independent features cannot regress simultaneously. The environment was lying. Frame callbacks do not fire in a page the browser is not compositing — a backgrounded tab, an offscreen iframe, some automation contexts — so every animated transition silently never ran. There is a real fix hiding in that story: anything that reaches its destination only by animating should have a way to arrive without one, because a visitor with a backgrounded tab hits the same stale state for real. **What all of them reduce to.** Every one of these renders a page a reviewer would sign off on. So the method is not "be careful" — it is **assert on what actually rendered, and break your own checks on purpose**. A count is not coverage, a success message is not a deploy, and a check that has never failed has told you nothing. ## Design decisions that came from the data - **A pin is a promise** — Locations with no storefront get no marker, or a deliberately different one — a dashed ring rather than a teardrop, and no directions link. The largest account on the reference build is online-only and correctly has nothing to drive to. - **Separate "can I see it" from "can I buy it"** — A customer who drives two hours expecting to buy and can only look is a worse outcome than one who knew going in. Cards say either "in stock — walk out with it today" or "demo only — they’ll order yours". This is the number-one documented locator complaint, and it costs one line of copy to prevent. - **The weakest tier became the most useful feature** — Locations with a storefront but no demo unit are the frustrating middle. Putting a prefilled "request an in-store demo" message on those cards — addressed **to the dealer**, sent from the customer’s own mail client, copied to the brand — converts that weakness into demand routing. A local buyer stating purchase intent is the argument for stocking, and it lands far harder from a customer than from the supplier. Naming the manufacturer inside that message matters more than it looks, because it gets forwarded. "Can you stock the mount" is unactionable; naming the product and the maker is a purchase order waiting to happen. The five that generalise: request a demo at this location (to them, copied to you) · a general enquiry · _nothing near me_ (to you) · arrange a pickup · become a dealer. ## Deploying it — the same stations, a different tax ### Hosted template platform - Records live in: Native structured records, shop-level - Ceiling: Template size cap and a row cap on every loop - Render locally: Possible — needs a shimmed engine - Preview: An unpublished theme, with a cache lag on every check ### Next.js on Vercel - Records live in: JSON in the repo, a database, or a headless CMS - Ceiling: None that matters here - Render locally: Native — the dev server IS the shipping renderer - Preview: A deployment per branch, no cache lag ### WordPress · Webflow · Squarespace - Records live in: Custom post type or CMS collection - Ceiling: Usually none; check the size a code block may be - Render locally: Hard — usually needs a local install - Preview: Staging, if you have one **Most of the hard parts of this method are hosted-platform tax.** On a framework stack everything inlines, the projection can happen at render time so the station-5 coupling largely disappears, and the local-render station is free because the dev server is the shipping renderer. What does _not_ change: the tier vocabulary, the independent booleans, the last-verified discipline, never constructing an email address, the pin-is-a-promise rule, and every finding in station 7. Those are about the data and the people, not the runtime. Neither does the script-termination hole — that is a property of putting JSON inside a script element, and it arrives in React with exactly the same shape and exactly the same fix. ## The two deployment paths, in order A worked demo of the finished artifact — the same stylesheet and runtime that shipped, with the data layer swapped for a fictional roster — is at https://jenso.ai/resources/find-a-dealer-map/demo.html. It is one self-contained HTML file, and it is path B running. The reference build's own production locator, with its real dealers, is at https://tentmount.com/pages/find-a-dealer. ### Path A — a hosted storefront platform Pick this when: Your store is on Shopify, BigCommerce or similar, a non-developer has to be able to fix a phone number, and the records must survive a theme swap. This is the path the reference build took. Records live in: Native structured records at the shop level (metaobjects), edited in the admin. 1. **Create the record type, dry run first** — Inspect the payload before committing anything. Then read the definition back and confirm public storefront read access and publishable status — not from the script’s own output, from the API. 2. **Seed ONE record, read it back** — Confirm it is active, not draft. API writes default to draft on most platforms and the template skips drafts entirely, so a successful seed produces an empty page. 3. **Render the template locally against that one live record** — This answers most of what a deploy would tell you, at no cost and with no cache. Needs a shimmed template engine with strict filters on, so an unknown platform filter throws instead of rendering nothing. 4. **Batch the rest, then re-render against live data** — Seed before you deploy: the first deploy then renders a full page, and a layout problem that only shows at twenty rows appears on the first look. 5. **Deploy scoped to an unpublished theme** — Diff the target against the theme it was cloned from — not against your working copy, which carries unrelated in-progress work and cannot separate "correctly scoped" from "everything went up". 6. **Publish, point the page at the template, link it from navigation** — The admin’s template picker only lists what is currently published, so the value usually has to be set through the API. A page nothing links to gets no traffic. **What bites on this path:** - **Wrap the loop in pagination on day one**, while it is a no-op at twenty records. Nobody remembers at record 51, nothing errors, and the page simply stops showing the rest of your data. - **Verify against the API, never the rendered page.** A page cache serves a stale render for roughly half an hour after a push or a write, and query-string cache-busting does not defeat it. ### Path B — your own site or app Pick this when: You control the HTML — a framework app, a static site, or a CMS with a custom-code block. Meaningfully simpler, and worth being honest that most of path A’s difficulty is platform tax rather than the method. Records live in: A JSON file in the repo, a database, or a headless CMS. Decide who owns edits FIRST. 1. **Take the artifact as-is** — One self-contained HTML file: brand tokens, optional motif, inline SVG map, one array of locations. No framework, no bundler, no key, no tile server. The demo linked above is exactly this file. 2. **Point the brand tokens at your own** — One block of CSS custom properties at the top. Namespace every one of them, or a host page declaring a bare --accent will overwrite yours and the failure will look like a bug in an unrelated component. 3. **Set the type base to a value you own** — Measure the host’s root font size before writing a single size. If the component owns its own base, it lands correctly whatever the host set; if it inherits, every number in your stylesheet means something other than what you wrote. 4. **Swap the data layer** — Replace the array. Project the coordinates with the same projection the geometry was built with — a build script, or at render time, but never by hand. 5. **Serve it** — Drop it in your static directory, or render the same markup from a component. If your platform serves files from a directory, check whether that directory is actually public — plenty of stacks gate it. 6. **Decide how a record gets corrected** — Records in the repo mean a dealer address change is a pull request. That is fine for a developer-owned page and wrong for a marketing-owned one. Revalidate on a webhook, read from a database, or point at a CMS. **What bites on this path:** - **The script-termination hole is not platform-specific.** Putting JSON inside a script element has the same hole in React, reached through a raw-HTML prop, with the same fix. - Most of the silent failures in station 8 — the draft trap, the row cap, the storefront-access toggle — simply do not exist here, so the validator gets shorter. **Keep the output assertions**: card count, payload parses, coordinates inside the viewBox, no internal fields in the HTML. Those catch data problems, and data problems are stack-agnostic. ## Rebuilding this from scratch Six inputs to settle before writing any code. The first is the one that actually blocks. 1. **The roster of places** — Which of your records describe a room a customer can stand in, with product in it? Gate: You can name every location and say where the answer came from. Not "the spreadsheet says so." 2. **Tier vocabulary** — What distinctions actually change what a visitor should do? Gate: Each tier implies a different action. If two tiers get the same card, they are one tier. 3. **Independent flags** — Which facts vary within a tier — stock, install, appointment-only? Gate: Flags are booleans on the record, never new tiers. 4. **Brand tokens** — Accent, surface, text, line, and the density shades Gate: They exist as CSS custom properties, namespaced, not hex codes in the markup. 5. **Motif — or none** — The decorative geometry, if any Gate: It comes from the product’s context. If you cannot say why it belongs, ship without it. 6. **Platform limits** — Row cap? Escape hatch? Does the escape hatch apply to every access pattern? Gate: Two separate questions. Answer both before choosing an architecture. Then fifteen steps, each ending in a gate. Do not proceed past a red gate. 1. Write the roster of record — human-owned, with provenance — gate: Every row traceable to a source you would defend 2. Verify contact data against the hierarchy — gate: Nothing sourced only from a search snippet 3. Build the standalone HTML file — gate: Opens in a browser and works. No build step, no dependencies 4. Get the artifact approved on look and behaviour — gate: Approved before any platform work 5. Pin the projection constants in exactly one file — gate: Nothing hardcodes a scale 6. Generate the seed data from the roster — gate: Zero hand-typed coordinates 7. Decide where each layer lives, on durability — gate: Records survive a template swap 8. Write the build-time validator — gate: It fails on a deliberately broken copy 9. Build the local render harness; assert on output — gate: Card count equals record count, at stress size 10. Mutation-test both — gate: At least one mutant survives and teaches you something 11. Dry run → commit → ONE record → render locally — gate: Every write read back from the API 12. Batch the rest; re-run the audit against LIVE data — gate: Zero drift, zero false positives 13. Deploy scoped to an unpublished environment — gate: Diff target against its source: only your files differ 14. Deploy to production, then link it from navigation — gate: A page nothing links to gets no traffic 15. Stand the read-only audit up as a recurring job — gate: It runs clean, and someone will notice when it does not Do not start by reading a shipped template. It is the output of every decision above and it will teach you the *what* while hiding all the *why*, which is the reverse of useful when you are about to make those decisions again for a different catalogue. ## What an assistant is never allowed to decide - **Decide whether a location gets a pin** → instead: Report what the sources say about a storefront, and ask the owner - **Construct an email address from a pattern** → instead: Link the contact page, and record that no address is published - **Resolve a conflict between two addresses** → instead: Present both with their sources ranked, and ask "move or addition?" - **Publish a business without its agreement** → instead: Ask — the reply doubles as an accuracy check - **Hand-edit a derived coordinate** → instead: Fix the address or the latitude behind it and regenerate - **Declare a deploy verified from a success message** → instead: Diff the target against its source and report the file counts **The general rule.** An assistant may gather, render, project, validate and report. It may never decide what a location promises a visitor, and it may never invent a way to contact one. Every one of those is a claim the business makes to a customer who is about to get in a car. ## Honest limits What it is: - A method, in eleven stations, each with a gate you have to pass before the next one. - One self-contained HTML file as the artifact — brand tokens, an optional motif, an inline SVG map, and one array of locations. - A four-layer separation, so corrections go into a human-owned roster and regenerate downward. - A field guide to contact-data drift, written from twenty real businesses. - A worked reference build with its own defects included, and a runbook that reproduces it. What it is not: - **Not a radius search.** Twenty locations fit on one screen. Search-by-postcode needs a postcode-to-coordinate table shipped as an asset — the public-domain gazetteer covers it, but it was not needed here and is not in the artifact. - **Not unlimited.** Past roughly 250 records the pagination escape hatch runs out and the architecture changes to client-side fetching. That is a "reconsider in years" threshold for a brand with twenty locations, not a flaw to pre-solve. - **The map needs JavaScript.** The geometry is too large to inline under the template size cap. The list renders server-side and works without JS; the map does not, and a failure is shown rather than hidden. - **Not a data-quality service.** It tells you your dealer data is wrong and gives you the hierarchy for fixing it. Somebody still has to make the calls, and station 7 is permanently a person. - **Not free of manual work.** Directories and map services return 403 to automated fetches or block scraping outright. A meaningful fraction of verification is manual, and any tool that says otherwise is returning lower-confidence data quietly. - **No authoritative precedent exists.** Despite native structured records being the obvious fit, we found no published engineering write-up of a records-backed locator at scale. The commercial apps sidestep the size cap by keeping data in their own backend. ## About these figures The reference build is Stark Side Gear (https://tentmount.com) — its figures, its live pages and its defects are real and quoted as measured. Verified 2026-08-12. Every figure here is measured from the reference build, not estimated. Volumetrics are given as ranges on purpose: a precise public number carries two permanent obligations — a defensible counting method, and somebody to re-derive it forever — and a decorative count earns neither. The figures stated exactly are the ones that carry an argument, and they are exact because without them there is no argument. The figures stated exactly, and why each one earns it: - 50 rows plain, 135 paginated, and still 50 from a parent's reference list — without all three, "the escape hatch does not apply everywhere" is an assertion rather than a result - roughly 85 rows unrendered for months on a page that looked perfect - 20 of 20 projected coordinates matching the approved artifact exactly — the difference between believing the pipeline is faithful and knowing it - 2 of 4 deliberately broken copies PASSING a reviewed validator - 0 files differing between a scoped deploy target and the theme it was cloned from — a provable statement, where "it said success" is not - 4 source lists agreeing on 6 names, which is the data blocker stated as a number - a host root font size of 10.5px, without which the unit story is unfalsifiable