One search script, 271 storefronts I don't control

Shipping a search overlay into 271 stores: rolling back @layer, race guards, Turkish folding, client-side typo tolerance and facets, and a debug catalogue.

Full-screen search overlay with product grid, category chips and a typed query, shown over a storefront

A client sent us screenshots of nine retail search panels and two commercial search products, with one question: what can we do differently? I went through every screenshot. Every difference I found was layout, and the engine underneath could already do all of it.

That answer turned into SearchKit, a preset engine on top of Search V2, the full-screen search overlay I'd been building since June. Today one script runs it on 271 storefronts at Revotas. I don't control a single one of those pages.

The overlay opens above the store's own page. One script adds it; the page underneath is not changed.

What I was up against

Before V2 I'd worked on After Search, a results-page widget with a config file and custom CSS per brand. It taught me how fast per-brand CSS turns into a support queue.

The overlay had harder rules:

  • One file. Vanilla JS, bundled by esbuild into a single IIFE. It builds its own DOM and injects its own CSS. Brands load it through a widget slot they already have.
  • Someone else's page. Every storefront has its own reset stylesheet, its own header markup, sometimes another search plugin, sometimes a consent banner that blocks our loader.
  • A backend I didn't write. Search runs on an existing Lucene-based endpoint the backend team owns. I built against it as a consumer. It has no typo tolerance, no working facets and no trending searches.
  • Turkish. Most of the catalogs and most of the queries.

V2 ended up around 3k lines. SearchKit is about 6.2k on top, and it imports V2's data layer instead of forking it.

The cascade war, and the rollback

My first instinct for CSS isolation was @layer: kit styles in a layer, brand overrides above it.

It broke on the first real storefronts. Unlayered styles beat layered ones no matter the specificity. So the brand's ordinary resets, *{margin:0}, button{border:0}, input{padding:0}, flattened our whole base. Padding went to zero. Chips turned into plain text. Grid gaps vanished.

I rolled @layer back. The setup that holds:

  • Kit base and preset tokens stay unlayered and win on specificity through a .sk prefix.
  • Panel CSS (what a colleague types for a specific brand) loads later in its own <style>, and the kit adds !important to it automatically. The rewriter is tested on 15 edge cases, like url('x;y.png') and rules inside @media.
  • Preset tokens are written as a CSS rule, not inline styles.

The test that guards this is cascade-test.html, 8 checks, all 8 must pass. Its first version missed the bug, because it tested the kit against a clean page. The current one includes a fake brand reset.

There's still one leftover. The prefixed reset .sk button outranks single-class rules like .sk-tab, so some tabs render at 16px instead of ~12px. The fix is to wrap the reset in :where() so it has zero specificity. Since that changes the look on every storefront at once, it goes out after a before/after measurement at 1440px.

Race control

Search-as-you-type fires a lot of requests, and the slow one always seems to land last. So there are two guards.

The data layer keeps one AbortController and every new search cancels the one in flight. The state store keeps a sequence number, and a response only gets applied if its number is still current. When a request hits the 10 second timeout, the sequence moves forward, so a late answer can't overwrite whatever the user is looking at now.

Loading has its own timeline. Nothing for the first 550 ms. Then a skeleton, but only if there are no previous results; otherwise the old grid stays, dimmed. A status line at 3 s, cancel and retry at 10 s.

The shared abort controller had a side effect. I wanted to pre-count results for each category chip in the background. Every probe would have cancelled the user's real search. So the counts are learned from actual clicks and hovers and cached for 24 hours.

Turkish text breaks the usual tricks

The common normalization recipe is NFD plus stripping diacritics. It doesn't work for ı. The dotless i is an atomic character with no decomposition, so it never becomes i. And toLocaleLowerCase('tr') alone turned PIKE into pıke, which then failed to match Pike and broke de-duplication. I wrote an explicit fold map instead.

Someone on an ASCII keyboard types "yemek takimi" and gets zero results. The kit suggests "Yemek Takımı", and that returns 20.

Category names arrive with mojibake (’ where ’ should be), so there's a repair table. They can't be URL-decoded either: in "%60'a Varan İndirim" (up to 60% off), decoding would eat the percent sign.

And prices. The shared parser stripped everything except digits and dots. Feed it 31.000,00 TL and you get 31.000, which parseFloat reads as 31. A 31,000 lira product sorted next to 31 lira ones.

Typo tolerance and facets, on the client

The backend has no typo tolerance, so the kit has a small one. It's a two-row Levenshtein against a dictionary built from category names, recommended queries and recent searches. Max distance 2, tightened to 1 for words of four letters or less ("bot" shouldn't become "not"). It only runs on zero results, offers one suggestion, and never rewrites the query silently.

Facets were worse. The endpoint ignores narrowing parameters: a minimum price of 900,000 returns the same list. The legacy client filter looked for field names that the API never sends, so facets had never worked at all. The kit derives facet values and counts from the returned products. Counts come from the full result set, so picking one facet doesn't wipe out the others, and a zero-count option never appears. The narrowing parameters still go out in the request, so if the backend team turns them on, it just works.

Accessibility on a page you don't own

The dropdown shell follows the WAI-ARIA APG combobox pattern. The split panel uses role=region instead, because a listbox can't contain tabs or a carousel.

A few rules I won't give up:

  • Hover and keyboard-active look different. Hover gets a 60% fill, keyboard-active gets a full fill plus a 2px accent bar. If they look the same, keyboard users lose the cursor.
  • In a suggestion, the completed part is bold, the typed part isn't.
  • Reduced motion drops movement but keeps fades.
  • Closing restores the brand's input exactly: no leftover role or aria-* attributes.

Last measurement, 10 presets at 1440px and 390px: zero overflow, zero targets under 24px, zero unlabeled controls.

The field-debugging catalogue

Most of my time went into "it doesn't open on our site." So I wrote down the order I check things in, before blaming the bundle:

  1. Count the configured input selector on the live page. It should match exactly 1 element, on every page type. One brand's selector matched 7 inputs, so clicking Login opened search.
  2. Check consent. Some sites don't load our widget loader until the visitor accepts cookies.
  3. Check the widget's trigger condition.
  4. Check for a stale config cached in session storage, which another open tab can rebroadcast.
  5. Check for a competing search plugin that swapped the search icon.

Each entry came from a real ticket.

Presets are data

SearchKit has 4 shells (fullscreen, dropdown, split, inline) and 16 blocks. Each block exposes the same contract: { element, update(state) }. A preset is a JSON description of which shell, which blocks in which zones, and which tokens. That's how 10 presets cover all nine screenshot layouts plus one discovery layout.

A brand picks a preset and deep-merges overrides on top. An unknown block is dropped with a warning, an unknown shell falls back to the default, so a config typo can't take the panel down.

Brands may override color, typography and shape. Spacing and grid stay global. That rule exists because V2 let brand CSS override anything, and every brand became its own CSS fix loop.

What I'd change

I'd build the cascade test with a hostile brand reset on day one, before choosing an isolation strategy. It would have killed @layer in an afternoon.

Real faceted search and real trending need server-side work; today's trending counter lives in one browser. The bundle isn't minified yet (about 64 KB gzipped, roughly 49 KB if it were). And the kit's UI strings are fixed in Turkish.

One script, 271 storefronts, zero control over any of them. The trick was assuming every page is hostile and writing the tests that way.

Try the interactive demo. More of my work at Revotas is on the Revotas page.

Have a problem worth solving?Let's make it reliable

Start a conversation

Open to full-stack and applied AI roles, remote or hybrid.

Tell me about the role, the team and the problem.