<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Forrest Miller</title>
    <description>The latest articles on DEV Community by Forrest Miller (@forrestmiller).</description>
    <link>https://dev.to/forrestmiller</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3872475%2F1b38c5a4-9313-4bc3-8bfd-a21db422888e.jpg</url>
      <title>DEV Community: Forrest Miller</title>
      <link>https://dev.to/forrestmiller</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/forrestmiller"/>
    <language>en</language>
    <item>
      <title>I shipped a Spanish bingo page by adding one typed object to a Next.js route</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 29 Jun 2026 18:34:39 +0000</pubDate>
      <link>https://dev.to/forrestmiller/i-shipped-a-spanish-bingo-page-by-adding-one-typed-object-to-a-nextjs-route-4om7</link>
      <guid>https://dev.to/forrestmiller/i-shipped-a-spanish-bingo-page-by-adding-one-typed-object-to-a-nextjs-route-4om7</guid>
      <description>&lt;p&gt;I shipped a Spanish entry point for &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; today: &lt;a href="https://bingwow.com/guides/bingo-online-gratis" rel="noopener noreferrer"&gt;Bingo Online Gratis&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part was the implementation shape. The app already had a guide route that worked well for English pages, so I wanted the Spanish page to reuse the same route, same card fetch logic, same JSON-LD builders, same sitemap path, and same internal-link rules.&lt;/p&gt;

&lt;p&gt;That meant the change was mostly data, plus a small typed extension to the guide template.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page is a typed content object
&lt;/h2&gt;

&lt;p&gt;The guide now carries optional metadata and locale fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Guide&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;metaTitle&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;metaDescription&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ogLocale&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;uiLocale&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Spanish page itself is a normal guide object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bingo-online-gratis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bingo Online Gratis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;metaTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bingo Online Gratis Sin Registro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ogLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es_MX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;uiLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bingo Online Gratis, Sin Registro y Sin App&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That let the existing route keep generating the page, Open Graph metadata, FAQ JSON-LD, HowTo JSON-LD, internal guide links, card recommendations, and sitemap entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The route owns repeated interface text
&lt;/h2&gt;

&lt;p&gt;The page body is Spanish content, but the shared template has repeated labels like "Updated", "Ready-Made Cards", and "Frequently Asked Questions".&lt;/p&gt;

&lt;p&gt;I added a tiny label map keyed by &lt;code&gt;uiLocale&lt;/code&gt; so the repeated labels switch to Spanish only for this page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;guide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uiLocale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Actualizado&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;readyMadeCards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tarjetas listas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;faqs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Preguntas frecuentes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;howToPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cómo jugar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;readyMadeCards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ready-Made Cards&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;faqs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Frequently Asked Questions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;howToPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;How to play&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared route still handles the layout. The Spanish guide only changes the content and labels it actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI-readable answer lives beside the page
&lt;/h2&gt;

&lt;p&gt;BingWow keeps a short answer file at &lt;a href="https://bingwow.com/llms.txt" rel="noopener noreferrer"&gt;llms.txt&lt;/a&gt;. I added a direct Spanish answer there too, with the canonical URL set to the new page.&lt;/p&gt;

&lt;p&gt;That gives crawlers and assistants a concise answer for terms like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bingo online gratis&lt;/li&gt;
&lt;li&gt;bingo gratis online&lt;/li&gt;
&lt;li&gt;jugar bingo online sin registro&lt;/li&gt;
&lt;li&gt;bingo virtual gratis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The live page and the answer file now point at the same canonical URL: &lt;a href="https://bingwow.com/guides/bingo-online-gratis" rel="noopener noreferrer"&gt;https://bingwow.com/guides/bingo-online-gratis&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checks caught a real content bug
&lt;/h2&gt;

&lt;p&gt;The first CI run failed because the acronym guard read Spanish sentence starts as English acronyms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Un&lt;/code&gt; looked like a broken &lt;code&gt;UN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Usa&lt;/code&gt; looked like a broken &lt;code&gt;USA&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix was to change the Spanish copy, not the test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Un enlace...&lt;/code&gt; became &lt;code&gt;Enlace único...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Usa 3x3...&lt;/code&gt; became &lt;code&gt;Elige 3x3...&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly why I like content tests in application repos. Editorial changes still move through the same quality gate as code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live page: &lt;a href="https://bingwow.com/guides/bingo-online-gratis" rel="noopener noreferrer"&gt;Bingo Online Gratis&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Short AI answer: &lt;a href="https://bingwow.com/llms.txt" rel="noopener noreferrer"&gt;llms.txt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Related no-signup English guide: &lt;a href="https://bingwow.com/guides/free-online-bingo-no-signup" rel="noopener noreferrer"&gt;Free online bingo with no signup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Create flow: &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;Create a custom bingo card&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The production deployment passed TypeScript, ESLint, Jest coverage, Next.js build, bundle-size check, and production smoke tests before I treated the page as live.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>i18n</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Bookmarklet, Chrome extension, or URL extractor? Three ways to open a clean recipe</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 29 Jun 2026 18:21:29 +0000</pubDate>
      <link>https://dev.to/forrestmiller/bookmarklet-chrome-extension-or-url-extractor-three-ways-to-open-a-clean-recipe-e4d</link>
      <guid>https://dev.to/forrestmiller/bookmarklet-chrome-extension-or-url-extractor-three-ways-to-open-a-clean-recipe-e4d</guid>
      <description>&lt;p&gt;There are three common ways to get from a messy recipe page to a clean cooking view:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste the recipe URL into a web extractor.&lt;/li&gt;
&lt;li&gt;Click a bookmarklet.&lt;/li&gt;
&lt;li&gt;Click a browser extension.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They look similar from the user's perspective, but they have different tradeoffs for permissions, mobile support, and reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. URL extractor
&lt;/h2&gt;

&lt;p&gt;The simplest path is a plain web form. Copy a public recipe URL, paste it into a tool, and open the cleaned result.&lt;/p&gt;

&lt;p&gt;That is the default &lt;a href="https://recipestripper.com" rel="noopener noreferrer"&gt;RecipeStripper&lt;/a&gt; workflow. It works in any modern browser, including mobile browsers, and it does not require an install before extraction.&lt;/p&gt;

&lt;p&gt;This is the best default when the cook found a recipe in search, Messages, email, Reddit, or a social app and just wants the clean page.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Bookmarklet
&lt;/h2&gt;

&lt;p&gt;A bookmarklet is a saved bookmark whose URL starts with JavaScript. When clicked from a recipe page, it opens the current URL in the cleaner.&lt;/p&gt;

&lt;p&gt;The advantage is minimal permissions. There is no extension package and no broad browser access request. RecipeStripper's &lt;a href="https://recipestripper.com/bookmarklet" rel="noopener noreferrer"&gt;bookmarklet page&lt;/a&gt; keeps the implementation intentionally small: grab the current page URL and send it to the clean reader.&lt;/p&gt;

&lt;p&gt;The tradeoff is setup friction, especially on mobile. Bookmarklets are powerful but less discoverable than a normal app button.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Chrome extension
&lt;/h2&gt;

&lt;p&gt;A Chrome extension gives the most familiar desktop workflow: click a toolbar button and open the current recipe in a clean reader.&lt;/p&gt;

&lt;p&gt;RecipeStripper's &lt;a href="https://recipestripper.com/chrome-extension" rel="noopener noreferrer"&gt;Chrome extension package&lt;/a&gt; uses Manifest V3 and keeps the job narrow. It is not injecting recipe UI into third-party pages. It opens the current tab's URL in RecipeStripper.&lt;/p&gt;

&lt;p&gt;The tradeoff is permissions and platform fit. Extensions are good on desktop Chrome. They are not the best answer for an iPhone on a kitchen counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the right surface
&lt;/h2&gt;

&lt;p&gt;If I were ranking the options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phone-first:&lt;/strong&gt; use the URL extractor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop, privacy-minimal:&lt;/strong&gt; use the bookmarklet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop, fastest repeat use:&lt;/strong&gt; use the Chrome extension.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a fuller comparison, I keep a guide to &lt;a href="https://recipestripper.com/blog/recipe-bookmarklets-and-extensions-2026" rel="noopener noreferrer"&gt;recipe bookmarklets and extensions&lt;/a&gt; and a broader ranking of &lt;a href="https://recipestripper.com/blog/best-recipe-extractors-without-ads-2026" rel="noopener noreferrer"&gt;recipe extractors without ads&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The important product principle is that the shortcut should stay boring. The hard work belongs in the extraction pipeline and the clean cooking view, not in a browser widget that tries to modify someone else's website.&lt;/p&gt;

&lt;p&gt;That is why every RecipeStripper entry point ultimately leads to the same destination: a clean recipe page with ingredients, steps, source attribution, serving controls, Cook Mode, and inline ingredient quantities.&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>webdev</category>
      <category>product</category>
      <category>foodtech</category>
    </item>
    <item>
      <title>Inline ingredient quantities: the missing feature in most clean recipe readers</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 29 Jun 2026 18:20:27 +0000</pubDate>
      <link>https://dev.to/forrestmiller/inline-ingredient-quantities-the-missing-feature-in-most-clean-recipe-readers-3ack</link>
      <guid>https://dev.to/forrestmiller/inline-ingredient-quantities-the-missing-feature-in-most-clean-recipe-readers-3ack</guid>
      <description>&lt;p&gt;Most "just the recipe" tools solve the first layer of annoyance: ads, popups, autoplay video, and the long introductory story.&lt;/p&gt;

&lt;p&gt;That is useful, but it does not fully solve cooking from a phone.&lt;/p&gt;

&lt;p&gt;The deeper problem is the two-list layout. Ingredients live in one section. Instructions live in another. Every instruction that says "add the butter" forces the cook to remember or re-check how much butter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean is not the same as cookable
&lt;/h2&gt;

&lt;p&gt;A clean recipe page can still be hard to use if it only copies the old structure. The cook still scrolls from the instruction to the ingredient list and back. That back-and-forth is the workflow tax.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://recipestripper.com" rel="noopener noreferrer"&gt;RecipeStripper&lt;/a&gt; approaches this as a matching problem. After extracting the recipe, it maps ingredient names into instruction steps and shows the matched quantity where the ingredient is used.&lt;/p&gt;

&lt;p&gt;A step that originally says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fold in the flour.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;can become a step that shows the quantity inline, such as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fold in 2 cups all-purpose flour.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The full ingredient list stays available. The inline amount is an assist, not a replacement for verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this belongs after extraction
&lt;/h2&gt;

&lt;p&gt;Recipe extraction already has uncertainty. Pages expose JSON-LD, Microdata, plugin-specific markup, or messy article HTML. RecipeStripper's &lt;a href="https://recipestripper.com/recipe-extractor" rel="noopener noreferrer"&gt;recipe extractor&lt;/a&gt; pipeline handles those layers before the matcher runs.&lt;/p&gt;

&lt;p&gt;Only after the app has a structured ingredient list and ordered steps does it attempt ingredient-to-step matching. That sequencing keeps the problem smaller and makes failure safer: uncertain matches can be left alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mobile payoff
&lt;/h2&gt;

&lt;p&gt;Inline quantities matter most on small screens. A laptop can show more context. A printed page can sit open on a counter. A phone shows a few lines at a time.&lt;/p&gt;

&lt;p&gt;That is why &lt;a href="https://recipestripper.com/recipe-without-scrolling" rel="noopener noreferrer"&gt;Recipe without scrolling&lt;/a&gt; is not just a marketing phrase. It describes a layout requirement: put the amount near the action.&lt;/p&gt;

&lt;p&gt;For longer cooking sessions, &lt;a href="https://recipestripper.com/blog/cook-mode-phone-screen-cooking" rel="noopener noreferrer"&gt;Cook Mode&lt;/a&gt; adds the other missing piece: keeping the screen awake while the clean recipe is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to compare tools
&lt;/h2&gt;

&lt;p&gt;When evaluating clean recipe readers, I would ask five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does it extract from a normal public recipe URL?&lt;/li&gt;
&lt;li&gt;Does it remove the original page's ad and modal scripts?&lt;/li&gt;
&lt;li&gt;Does it keep source attribution visible?&lt;/li&gt;
&lt;li&gt;Does it handle serving-size changes?&lt;/li&gt;
&lt;li&gt;Does it reduce back-scrolling between ingredients and steps?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A tool that only handles the first two is a cleaner page. A tool that handles all five is closer to a kitchen interface. That is the bar behind &lt;a href="https://recipestripper.com/clean-recipe-viewer" rel="noopener noreferrer"&gt;RecipeStripper's clean recipe viewer&lt;/a&gt; and the comparison in &lt;a href="https://recipestripper.com/blog/best-tools-to-read-recipes-without-scrolling-2026" rel="noopener noreferrer"&gt;Best tools to read recipes without scrolling&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ux</category>
      <category>webdev</category>
      <category>foodtech</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Mobile recipe websites in 2026: why clean cooking pages beat recipe cards</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 29 Jun 2026 18:20:25 +0000</pubDate>
      <link>https://dev.to/forrestmiller/mobile-recipe-websites-in-2026-why-clean-cooking-pages-beat-recipe-cards-4mc4</link>
      <guid>https://dev.to/forrestmiller/mobile-recipe-websites-in-2026-why-clean-cooking-pages-beat-recipe-cards-4mc4</guid>
      <description>&lt;p&gt;Most recipe-site engineering is optimized for publishing: long article bodies, ad slots, recipe-card plugins, social widgets, newsletter modals, and enough structured data for search engines to understand the page.&lt;/p&gt;

&lt;p&gt;Cooking from a phone is a different job. A phone on a counter needs one thing: the recipe, readable at a glance, without losing your place.&lt;/p&gt;

&lt;p&gt;That is why I treat &lt;strong&gt;mobile recipe websites&lt;/strong&gt; as a workflow problem rather than a content-design problem. The page can have excellent Schema.org Recipe markup and still be annoying when the cook is trying to move from step 2 to step 3 with messy hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the mobile reader actually needs
&lt;/h2&gt;

&lt;p&gt;A usable mobile recipe page has a smaller checklist than most publishers imagine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The title is visible.&lt;/li&gt;
&lt;li&gt;Ingredients and steps are extracted into a stable layout.&lt;/li&gt;
&lt;li&gt;Ads, sticky video, popups, and unrelated prose are gone.&lt;/li&gt;
&lt;li&gt;Ingredient quantities stay near the step that uses them.&lt;/li&gt;
&lt;li&gt;The screen can stay awake while the cook works.&lt;/li&gt;
&lt;li&gt;The source URL is still credited.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the exact shape behind &lt;a href="https://recipestripper.com" rel="noopener noreferrer"&gt;RecipeStripper&lt;/a&gt;. It is a free browser tool: paste a public recipe URL and get a clean cooking page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why recipe cards are not enough
&lt;/h2&gt;

&lt;p&gt;A WordPress recipe card is better than a wall of text, but it still preserves the old cookbook split: ingredients at the top, instructions below. That is fine on paper. It is weaker on a phone, because every step that says "add the flour" sends the cook back to the ingredient list.&lt;/p&gt;

&lt;p&gt;The better layout is to keep the full ingredient list available while also embedding matched quantities inside the step text. RecipeStripper explains that workflow on its &lt;a href="https://recipestripper.com/recipe-without-scrolling" rel="noopener noreferrer"&gt;recipe without scrolling&lt;/a&gt; page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile query surfaces matter
&lt;/h2&gt;

&lt;p&gt;Search and answer engines increasingly answer queries such as "ad-free recipe reader for my phone" or "mobile recipe websites 2026" with a short list of tools. The page that wins is usually the page that states the mobile job clearly.&lt;/p&gt;

&lt;p&gt;For the broad website comparison, I keep a human-readable guide at &lt;a href="https://recipestripper.com/blog/best-recipe-websites-2026" rel="noopener noreferrer"&gt;Best Recipe Websites 2026&lt;/a&gt;. For the actual cooking workflow, the better entry point is &lt;a href="https://recipestripper.com/clean-recipe-viewer" rel="noopener noreferrer"&gt;Clean Recipe Viewer&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engineering constraint
&lt;/h2&gt;

&lt;p&gt;A clean recipe reader should not pretend every website is extractable. Bot-protected, paywalled, image-only, or login-only pages can fail. That boundary is part of trust. RecipeStripper keeps a &lt;a href="https://recipestripper.com/works-with" rel="noopener noreferrer"&gt;Works With directory&lt;/a&gt; so support claims stay inspectable.&lt;/p&gt;

&lt;p&gt;The practical recommendation is simple: use publisher recipe sites for discovery, then open the recipe in a purpose-built clean reader before cooking. If the source page is cluttered, paste it into &lt;a href="https://recipestripper.com/recipe-without-ads" rel="noopener noreferrer"&gt;RecipeStripper&lt;/a&gt; and cook from the cleaned view instead.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>foodtech</category>
      <category>seo</category>
      <category>ai</category>
    </item>
    <item>
      <title>Date-Scoped Travel Pages Turned Into ValidaTrip’s First Unbranded Search Wedge</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 02 Jun 2026 12:57:04 +0000</pubDate>
      <link>https://dev.to/forrestmiller/date-scoped-travel-pages-turned-into-validatrips-first-unbranded-search-wedge-3679</link>
      <guid>https://dev.to/forrestmiller/date-scoped-travel-pages-turned-into-validatrips-first-unbranded-search-wedge-3679</guid>
      <description>&lt;p&gt;On June 2, 2026, I pulled fresh Search Console data for &lt;a href="https://www.validatrip.com/" rel="noopener noreferrer"&gt;ValidaTrip&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The site had 319 clicks and 11,459 impressions across the prior 28 days. Visible unbranded query rows accounted for 40 clicks and 1,493 impressions.&lt;/p&gt;

&lt;p&gt;That mattered because the prior baseline was branded-only.&lt;/p&gt;

&lt;p&gt;The first unbranded wedge was not a generic AI travel term. It was date-scoped city event search.&lt;/p&gt;

&lt;p&gt;Queries like “madrid events july 2026” and “stockholm events july 2026” sent clicks. That is small, but it is real.&lt;/p&gt;

&lt;p&gt;The pages receiving that demand were built as city-month surfaces, not blog posts.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/things-to-do/madrid/july-2026" rel="noopener noreferrer"&gt;Madrid events in July 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/things-to-do/stockholm/july-2026" rel="noopener noreferrer"&gt;Stockholm events in July 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/things-to-do/mexico-city/november-2026" rel="noopener noreferrer"&gt;Mexico City events in November 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The implementation point is simple. Travel search has a date dimension that generic itinerary pages often ignore.&lt;/p&gt;

&lt;p&gt;A traveler does not only ask for “things to do in Madrid.” They ask what is happening while they are there.&lt;/p&gt;

&lt;p&gt;That changes the content shape.&lt;/p&gt;

&lt;p&gt;A useful travel page needs the destination, the month, the event window, and the itinerary context. It also needs a reason to exist beyond a list.&lt;/p&gt;

&lt;p&gt;ValidaTrip already checks trip plans against opening hours, closures, holidays, bookings, neighborhoods, and maps.&lt;/p&gt;

&lt;p&gt;The city-month pages connect that checker to demand that already has dates baked in.&lt;/p&gt;

&lt;p&gt;The product flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A traveler searches for events in a city and month.&lt;/li&gt;
&lt;li&gt;They land on a date-scoped city page.&lt;/li&gt;
&lt;li&gt;They see relevant events, attractions, and trip timing context.&lt;/li&gt;
&lt;li&gt;They paste their own plan into &lt;a href="https://www.validatrip.com/validate-trip-hours" rel="noopener noreferrer"&gt;the trip-hours validator&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;They catch closed places, booking-sensitive stops, and missed event windows before the trip.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same surface supports AI travel checks.&lt;/p&gt;

&lt;p&gt;ChatGPT can write a plausible travel plan without knowing live hours. It can also miss a festival that overlaps the exact trip.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;ChatGPT itinerary checker&lt;/a&gt; handles the post-AI reality check.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.validatrip.com/guides/missed-events-and-festivals" rel="noopener noreferrer"&gt;missed events and festivals guide&lt;/a&gt; explains the event problem directly.&lt;/p&gt;

&lt;p&gt;The public AI citation layer mirrors that answer in &lt;a href="https://www.validatrip.com/llms-full.txt" rel="noopener noreferrer"&gt;llms-full.txt&lt;/a&gt;. That file gives AI search systems canonical wording and canonical URLs.&lt;/p&gt;

&lt;p&gt;The ranking lesson is not “publish more pages.”&lt;/p&gt;

&lt;p&gt;It is “publish pages where a specific traveler intent already includes a city, date, and action.”&lt;/p&gt;

&lt;p&gt;For ValidaTrip, that intent is not abstract.&lt;/p&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What events are in this city during my trip?&lt;/li&gt;
&lt;li&gt;Is this place open when I arrive?&lt;/li&gt;
&lt;li&gt;Does this AI itinerary work on real dates?&lt;/li&gt;
&lt;li&gt;Can I turn these notes into a checked map?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions map to pages with a clear job.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/destinations" rel="noopener noreferrer"&gt;Find the city-month event page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/validate-trip-hours" rel="noopener noreferrer"&gt;Check the trip hours&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;Validate a ChatGPT itinerary&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.validatrip.com/guides/missed-events-and-festivals" rel="noopener noreferrer"&gt;Read the event-overlap guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The traffic is early. The mechanism is visible now.&lt;/p&gt;

&lt;p&gt;Search Console showed unbranded clicks after the site exposed city-month event pages with clean URLs and crawlable content.&lt;/p&gt;

&lt;p&gt;That is the surface I am doubling down on.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>buildinpublic</category>
      <category>marketing</category>
      <category>startup</category>
    </item>
    <item>
      <title>I Built a Free Bingo Caller Board With 331 Audio Clips and No Backend</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Tue, 02 Jun 2026 12:28:40 +0000</pubDate>
      <link>https://dev.to/forrestmiller/i-built-a-free-bingo-caller-board-with-331-audio-clips-and-no-backend-3dad</link>
      <guid>https://dev.to/forrestmiller/i-built-a-free-bingo-caller-board-with-331-audio-clips-and-no-backend-3dad</guid>
      <description>&lt;p&gt;A bingo caller board has four jobs: draw a number without repeats, say it out loud, mark it on a flashboard, and leave enough history on screen to check a winner. I built that as a browser-only feature for &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, because the room already has one host and one screen. There is no shared state worth sending to a server.&lt;/p&gt;

&lt;p&gt;The live version is here: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;. It supports 75-ball, &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;90-ball&lt;/a&gt;, and &lt;a href="https://bingwow.com/caller/30-ball" rel="noopener noreferrer"&gt;30-ball speed bingo&lt;/a&gt;, with voice calls and a fullscreen board for a TV or projector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caller is local state
&lt;/h2&gt;

&lt;p&gt;A multiplayer bingo game needs a server because every player has an independent board and every tap needs authority. A standalone caller does not. One host runs it. The output is public in the room. Refreshing starts a new game, which is normal for a caller.&lt;/p&gt;

&lt;p&gt;The main state is a reducer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CallerState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BallMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// '30' | '75' | '90'&lt;/span&gt;
  &lt;span class="nl"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;          &lt;span class="c1"&gt;// shuffled, pop() to draw&lt;/span&gt;
  &lt;span class="nl"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BingoBall&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;calledSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// O(1) caller-board lookup&lt;/span&gt;
  &lt;span class="nl"&gt;isAutoMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;roundNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DRAW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;makeBall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller sends no request when a number is drawn. The deck is shuffled in the tab, the flashboard reads the called set, and the auto-call timer advances only after the current animation has completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I skipped the Web Speech API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;speechSynthesis.speak()&lt;/code&gt; looked like the cheap answer. It failed the product test.&lt;/p&gt;

&lt;p&gt;The available voices differ by OS and browser. A calm voice on a Mac became a different voice on a Chromebook. Rapid calls also queued badly during auto-call, and traditional 90-ball nicknames sounded flat when a browser voice read them.&lt;/p&gt;

&lt;p&gt;The shipped caller uses 331 prerecorded MP3s instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;number calls for 30, 75, and 90-ball games&lt;/li&gt;
&lt;li&gt;every traditional 90-ball nickname, from "Legs eleven" to "Two fat ladies"&lt;/li&gt;
&lt;li&gt;welcome, pause, progress, and round-transition clips&lt;/li&gt;
&lt;li&gt;short clips that fit between visual calls at faster speeds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes the &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;90-ball bingo caller&lt;/a&gt; sound the same in a classroom, a senior center, a church hall, or a browser tab on a TV.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flashboard is the product
&lt;/h2&gt;

&lt;p&gt;Most users describe the same surface as a bingo caller board, bingo calling board, or flashboard. The code treats it as a pure display component: it does not own the game; it only renders the current mode and called numbers.&lt;/p&gt;

&lt;p&gt;75-ball uses B-I-N-G-O columns. 90-ball and 30-ball use number ranges. The shared caller component seeds the mode from the route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// /caller defaults to 75-ball&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CallerClient&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// /caller/90-ball&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CallerClient&lt;/span&gt; &lt;span class="na"&gt;initialMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"90"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// /caller/30-ball&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CallerClient&lt;/span&gt; &lt;span class="na"&gt;initialMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"30"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave each mode its own indexable page while keeping one caller implementation. The 90-ball page also includes an interactive list of all 90 traditional calls, built from the same audio manifest used by the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio fires from the animation timeline
&lt;/h2&gt;

&lt;p&gt;The drawn ball flies into its cell on the flashboard. The number needs to be spoken at impact, not when React finishes rendering. The first implementation keyed audio from a state effect, and it drifted during auto-call.&lt;/p&gt;

&lt;p&gt;The fix was to fire audio from the animation callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;runFlyingBallToCell&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;ball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;targetCell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;onAbsorbed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCellRevealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;voiceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;playBallImpact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lingoEnabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeline owns timing, the reducer owns state, and the flashboard stays a pure read model. That split kept manual draw, auto-call, voice mute, and fullscreen mode from fighting each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Printing completes the offline game
&lt;/h2&gt;

&lt;p&gt;A free caller alone is only half a bingo night. Players still need cards. The 75-ball caller has a Print Cards flow that generates up to 500 unique cards with validation codes. The support guide is here: &lt;a href="https://bingwow.com/blog/free-online-bingo-caller" rel="noopener noreferrer"&gt;Free online bingo caller&lt;/a&gt;, and the card printer is here: &lt;a href="https://bingwow.com/print" rel="noopener noreferrer"&gt;bingwow.com/print&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The practical setup is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;free caller board&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Print cards or hand out existing tickets.&lt;/li&gt;
&lt;li&gt;Use fullscreen on a TV or projector.&lt;/li&gt;
&lt;li&gt;Start manual draw or auto-call.&lt;/li&gt;
&lt;li&gt;Check the winner against the call history.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try the caller
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;75-ball caller board: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;90-ball UK caller: &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;bingwow.com/caller/90-ball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;30-ball speed caller: &lt;a href="https://bingwow.com/caller/30-ball" rel="noopener noreferrer"&gt;bingwow.com/caller/30-ball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;No-equipment host guide: &lt;a href="https://bingwow.com/blog/free-online-bingo-caller" rel="noopener noreferrer"&gt;free online bingo caller&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bingo night guide: &lt;a href="https://bingwow.com/blog/how-to-host-bingo-night" rel="noopener noreferrer"&gt;how to host bingo night&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Six layers to canonicalize 'FiDi', 'Wall Street area', and 'Lower Manhattan' as one neighborhood</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:10:33 +0000</pubDate>
      <link>https://dev.to/forrestmiller/six-layers-to-canonicalize-fidi-wall-street-area-and-lower-manhattan-as-one-neighborhood-5cb1</link>
      <guid>https://dev.to/forrestmiller/six-layers-to-canonicalize-fidi-wall-street-area-and-lower-manhattan-as-one-neighborhood-5cb1</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A user pastes their travel list into &lt;a href="https://www.validatrip.com/" rel="noopener noreferrer"&gt;our travel-paste validator&lt;/a&gt;. They got recs from a friend, a blog, an AI itinerary, and a Reddit thread. The list mentions Lower Manhattan four ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"FiDi"&lt;/li&gt;
&lt;li&gt;"Wall Street area"&lt;/li&gt;
&lt;li&gt;"Financial District NYC"&lt;/li&gt;
&lt;li&gt;"Lower Manhattan financial district"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our Neighborhoods tab groups by area so users can &lt;a href="https://www.validatrip.com/guides/plan-trip-days-by-neighborhood" rel="noopener noreferrer"&gt;plan a day at a time&lt;/a&gt;. Without canonicalization, those four labels become four area cards with one place each. The user can't tell that they're looking at one walkable district. The product looks broken.&lt;/p&gt;

&lt;p&gt;This is a small example. The full surface across 145 cities is thousands of variants per city — formal vs informal, English vs local, Wikipedia title vs administrative subdivision, polygon-level granularity vs guidebook vocabulary. Solving it in one pass with a single API or a single LLM call is the obvious wrong answer.&lt;/p&gt;

&lt;p&gt;We solved it in six layers. Each layer covers a class of variants the next layer can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: spatial reverse-geocode
&lt;/h2&gt;

&lt;p&gt;When a validated item has &lt;code&gt;(lat, lng)&lt;/code&gt;, the polygon-based name overrides whatever text label the upstream Google Places match returned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/providers/places/spatial-reverse.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchAddressDescriptors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GOOGLE_GEOCODING_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;areas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;addressDescriptors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;areas&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="c1"&gt;// areas are returned smallest-to-largest with WITHIN / NEAR / OUTSKIRTS&lt;/span&gt;
&lt;span class="c1"&gt;// take the smallest WITHIN — the most specific containing area&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;areas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;containment&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WITHIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Google's call fails, we fall through to Mapbox v6 reverse with &lt;code&gt;types=neighborhood,locality,place&lt;/code&gt;. Result is cached per rounded-coord (4 decimal places, ~10 m precision) so adjacent items in the same neighborhood share a single billable call.&lt;/p&gt;

&lt;p&gt;This collapses the trivial split: two items that physically sit inside Tribeca will both come back labeled "Tribeca", even if one was pasted as "near the Holland Tunnel exit" and the other as "by the Mysterious Bookshop."&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: bulk alias mining via Gemini
&lt;/h2&gt;

&lt;p&gt;The spatial pass handles items with coordinates. About 2.5% of real-world pastes have no coordinates because the title alone failed to match any place ("our friend's apartment in BoCoCa", "the Marais restaurant Maya recommended"). For those, we lean on a per-city alias dictionary.&lt;/p&gt;

&lt;p&gt;We mined the dictionary once with a single Gemini 2.5 Flash call per city:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/places/mine-aliases-gemini.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`For each canonical neighborhood in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, list the
informal abbreviations, prefix-stripped forms, article variations,
multilingual transliterations, and common misspellings real travelers use.

Return strict JSON: { "&amp;lt;canonical&amp;gt;": ["alias1", "alias2", ...] }

Canonical list:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;canonicalEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We tried this against the Wikidata Action API first. The anon &lt;code&gt;wbsearchentities&lt;/code&gt; + &lt;code&gt;wbgetentities&lt;/code&gt; flow rate-limits at roughly 1 request per second with 11-second &lt;code&gt;Retry-After&lt;/code&gt; headers under load. At ~75K canonical entries with ~4 fetches each, the wall-clock cost is days.&lt;/p&gt;

&lt;p&gt;The Gemini call returns aliases for an entire city in one shot with hallucination guards: a returned alias is dropped if it matches another canonical entry in the same city (those are distinct neighborhoods), and aliases containing "X and Y" are dropped when the canonical doesn't (catches "Camden Town and Regent's Park" pseudo-combinations).&lt;/p&gt;

&lt;p&gt;Result: FiDi → Financial District. DUMBO → Down Under the Manhattan Bridge Overpass. Le Marais → Marais. La Roma → Roma. 渋谷 → Shibuya. The 4-spelling NYC FiDi example collapses at this layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Overture Maps polygon point-in-polygon
&lt;/h2&gt;

&lt;p&gt;Spatial reverse-geocode handles coords. The alias dictionary handles labels. Neither handles the case where a Google Places result returns the right polygon but at the wrong level of administrative granularity.&lt;/p&gt;

&lt;p&gt;NYC is the canonical example. Overture's NYC neighborhood polygons are at Community Board granularity. CB 5 covers Midtown East, Midtown West, AND Murray Hill — three guidebook neighborhoods folded into one admin zone. If we wrote the polygon's raw name to the item, the user would see a "Manhattan Community Board 5" area card instead of the three distinct neighborhoods they care about.&lt;/p&gt;

&lt;p&gt;We pre-bake the polygons once per release with DuckDB against Overture's S3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;duckdb &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
  COPY (
    SELECT id, names.primary AS name, subtype, bbox, geometry
    FROM read_parquet('s3://overturemaps-us-west-2/release/2026-05-20.0/theme=divisions/type=division_area/*')
    WHERE subtype IN ('neighborhood','microhood','macrohood','borough','localadmin')
      AND ST_Intersects(geometry, ST_GeomFromText('&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cityBbox&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'))
  ) TO 'polygons-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;slug&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.geo.json' (FORMAT 'GeoJSON');
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At validate time, we lazy-load the city's &lt;code&gt;.geo.json&lt;/code&gt;, build a &lt;code&gt;flatbush&lt;/code&gt; R-tree from pre-computed bboxes, run &lt;code&gt;@turf/boolean-point-in-polygon&lt;/code&gt; to find every enclosing polygon, and pick the most specific one by subtype rank:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUBTYPE_RANK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;microhood&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;neighborhood&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;macrohood&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;borough&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;localadmin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-city "too-coarse" skip patterns prevent the granularity downgrade. NYC skips Community Board names so the LLM resolver (Layer 5) keeps its more specific guess. London skips borough names like "City of Westminster" because that single borough covers Mayfair / Soho / Marylebone / Covent Garden / Westminster / St James's — six guidebook neighborhoods. Mexico City skips the 13 alcaldía names.&lt;/p&gt;

&lt;p&gt;The skip list is per-name, not per-subtype, because Overture occasionally labels admin areas at the &lt;code&gt;neighborhood&lt;/code&gt; subtype.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: POI gazetteer (Wikidata-derived)
&lt;/h2&gt;

&lt;p&gt;Some titles are famous enough that the title itself tells you the neighborhood. "Whitney Museum of American Art" implies Meatpacking District. "Tate Modern" implies South Bank. We mined a per-city &lt;code&gt;(title → neighborhood)&lt;/code&gt; gazetteer from Wikidata SPARQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sparql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?poiLabel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhoodLabel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?landmarkType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q33506&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q1244442&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q12876&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q35112&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;Q860861&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P31&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P279&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?landmarkType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;cityQid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="k"&gt;UNION&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?neighborhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?parent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?parent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P131&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;cityQid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;FILTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;EXISTS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?poi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wdt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;P576&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;?dissolved&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;SERVICE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;label&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;bd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;serviceParam&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;wikibase&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="ss"&gt;language&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives us roughly 8,600 entries across 15 cities. Lookup is foldKey-exact only (we tried substring matching once — 14 of 23 hits on a live Paris audit were false positives, so exact-only is the conservative choice). The gazetteer file is module-cached after first load.&lt;/p&gt;

&lt;p&gt;This catches the long tail Layer 3 polygons can't catch: a paste that says "Whitney" with no coordinates and no neighborhood label resolves to Meatpacking District because the gazetteer recognizes the abbreviation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: per-trip LLM canonical resolver
&lt;/h2&gt;

&lt;p&gt;After the four deterministic layers, some labels still don't match. "Historic center of Mexico City", "Colonia Centro", "Centro Histórico", and "Centro" all describe the same neighborhood but the alias dictionary only had three of them.&lt;/p&gt;

&lt;p&gt;The fifth layer runs ONE &lt;code&gt;gpt-4o-mini&lt;/code&gt; call per trip that maps every distinct raw label to the canonical guidebook name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/validation/resolve-canonical-neighborhoods.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Map each raw label to the closest canonical neighborhood in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.

If a label doesn't clearly match any canonical entry, return it unchanged.
Don't guess across cities. Don't invent new canonical names.

Canonical: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;canonicalNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
Raw labels in this trip: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rawNeighborhoods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

Return JSON: { "&amp;lt;raw&amp;gt;": "&amp;lt;canonical or raw&amp;gt;", ... }`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two defensive filters at the boundary: the response is filtered against &lt;code&gt;Set(rawNeighborhoods)&lt;/code&gt; AND &lt;code&gt;Set(canonicalNames)&lt;/code&gt;. Any hallucinated key that isn't in the input set is dropped silently. Any value that isn't in the canonical set is dropped silently. The LLM can never hand the database a name we didn't ask about.&lt;/p&gt;

&lt;p&gt;One call per trip, not per item. A 30-item trip costs the same as a 3-item trip. Latency is amortized into the existing validate step, which already takes 8 seconds.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; is missing or the call fails, the layer is a no-op. The four deterministic layers above are sufficient for the common cases; the LLM is a long-tail backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 6: per-city consolidation maps
&lt;/h2&gt;

&lt;p&gt;After all five layers, polygon-real names sometimes still aren't the guidebook canonical. Overture subdivides Polanco into "Polanco 3ª Sección", "Polanco 4ª Sección", "Polanco 5ª Sección". Paris arrondissements arrive in 50+ surface forms ("14th arrondissement of Paris", "Paris 14e Arrondissement", "14th Arrondissement").&lt;/p&gt;

&lt;p&gt;A small per-city map collapses each polygon-real subdivision into the guidebook canonical via foldKey lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CANONICAL_CONSOLIDATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mexico city|MX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polanco 3a seccion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Polanco&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polanco 4a seccion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Polanco&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polanco 5a seccion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Polanco&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;morelos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Centro Histórico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;guerrero&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Centro Histórico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;buenavista&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Centro Histórico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// 30+ more&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paris|FR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14th arrondissement of paris&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14th Arrondissement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paris 14e arrondissement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14th Arrondissement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// 48+ more arrondissement variants&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a new polygon raw name appears in &lt;code&gt;verify-pass-d&lt;/code&gt; that should fold to an existing canonical, the fix is to add it here, not to teach the LLM to handle it stochastically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why six layers, and not one LLM call
&lt;/h2&gt;

&lt;p&gt;A single LLM call would work for any individual case. It would also cost more per validate, take longer, hallucinate occasionally, and degrade silently when the model drifts between releases.&lt;/p&gt;

&lt;p&gt;Each deterministic layer handles a class of variants the cheaper and more stable way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Strength&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Spatial reverse-geocode&lt;/td&gt;
&lt;td&gt;Exact answer when coords exist&lt;/td&gt;
&lt;td&gt;~1 Geocoding API call per unique coord&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Gemini alias mining&lt;/td&gt;
&lt;td&gt;Handles label-only no-coord cases&lt;/td&gt;
&lt;td&gt;Mined ONCE per city, $0 per validate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Overture polygon PIP&lt;/td&gt;
&lt;td&gt;Handles weird Google Places labels&lt;/td&gt;
&lt;td&gt;Lazy-load + R-tree, no API call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. POI gazetteer&lt;/td&gt;
&lt;td&gt;Famous landmarks by title alone&lt;/td&gt;
&lt;td&gt;foldKey-exact lookup, no API call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. LLM canonical resolver&lt;/td&gt;
&lt;td&gt;Long-tail rephrasings&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;gpt-4o-mini&lt;/code&gt; call per trip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. Consolidation map&lt;/td&gt;
&lt;td&gt;Polygon-real → guidebook canonical&lt;/td&gt;
&lt;td&gt;Static map lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The LLM is layer five, not layer one, because by the time we reach it, 95% of the labels have been resolved deterministically. The remaining 5% are exactly the cases where its strength — pattern-matching across spellings — matters most.&lt;/p&gt;

&lt;p&gt;That same logic applies to most "the data is messy" problems in travel software. Real pastes are heterogeneous because real users are heterogeneous. &lt;a href="https://www.validatrip.com/things-to-do/london/june-2026" rel="noopener noreferrer"&gt;A London trip&lt;/a&gt; has Tube-stop names, Wikipedia titles, blog rephrasings, and Reddit short-hand all in the same paste. The cheapest path to a clean Neighborhoods tab is to peel the layers in order: geometry first, dictionaries second, structured catalogs third, LLM last.&lt;/p&gt;

&lt;p&gt;If you want to see the result, paste anything messy into &lt;a href="https://www.validatrip.com/trips/new" rel="noopener noreferrer"&gt;a new trip&lt;/a&gt; and open the Neighborhoods tab. The canonicalization runs on every validate. The full canonical guidebook lives at &lt;a href="https://www.validatrip.com/destinations" rel="noopener noreferrer"&gt;our destinations page&lt;/a&gt; if you want to see what we're matching against, or &lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;check a ChatGPT itinerary&lt;/a&gt; if you want to see the same pipeline applied to LLM-generated travel plans.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>1,071 of our 1,190 moderation-queue cards were test pollution. Here's the 5-layer fix.</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:10:12 +0000</pubDate>
      <link>https://dev.to/forrestmiller/1071-of-our-1190-moderation-queue-cards-were-test-pollution-heres-the-5-layer-fix-1n9c</link>
      <guid>https://dev.to/forrestmiller/1071-of-our-1190-moderation-queue-cards-were-test-pollution-heres-the-5-layer-fix-1n9c</guid>
      <description>&lt;h2&gt;
  
  
  The bug we shipped twice
&lt;/h2&gt;

&lt;p&gt;Our test fixtures leaked into production. Twice.&lt;/p&gt;

&lt;p&gt;The first leak was visible. 117 QA cards reached &lt;code&gt;/cards/education/science&lt;/code&gt; because their titles looked enough like real cards to slip past the title-pattern heuristic our public surfaces used. We cleaned them up in May.&lt;/p&gt;

&lt;p&gt;The second leak was invisible until I audited the moderation queue last week. &lt;strong&gt;1,071 of 1,190 cards waiting for human review were automation pollution&lt;/strong&gt; — Playwright fixtures, a &lt;code&gt;QA 65678&lt;/code&gt; test account, timestamped runs named &lt;code&gt;PW Test&lt;/code&gt;, &lt;code&gt;Security Test&lt;/code&gt;, &lt;code&gt;Mobile PW&lt;/code&gt;, plus high-volume anon seeds like &lt;code&gt;Road Trip Bingo&lt;/code&gt; (×217) and &lt;code&gt;Test Merged Create&lt;/code&gt; (×128). They had been queued up for the AI moderator as if a human had submitted them.&lt;/p&gt;

&lt;p&gt;Neither leak was a bug in any single code path. The bug was that "is this a test row?" was a guess every consumer made on its own. So I wrote down the rule once and enforced it five ways.&lt;/p&gt;

&lt;p&gt;This is the playbook, with the actual Postgres + TypeScript that runs in &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "is this a test row?" is hard
&lt;/h2&gt;

&lt;p&gt;Test fixtures look exactly like real records. They use the same INSERT path. They carry the same shape. The only difference is intent: a human typed one, a Playwright spec typed the other.&lt;/p&gt;

&lt;p&gt;Pre-2026, our public surfaces filtered tests with title heuristics — strings like &lt;code&gt;QA test card&lt;/code&gt; or &lt;code&gt;[playwright]&lt;/code&gt; were dropped at query time. That worked until a Shiplight test forgot to include the magic substring. Then the row was indistinguishable.&lt;/p&gt;

&lt;p&gt;The fix Stripe shipped is the canonical reference: every record carries a &lt;code&gt;livemode&lt;/code&gt; boolean set at INSERT time by whichever code path created it. Public surfaces filter on it; analytics segment by it; cleanup automates on it.&lt;/p&gt;

&lt;p&gt;We needed the same shape, expanded to an enum-of-strings because we have more origins than test/prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: the origin column (NOT NULL + CHECK)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;card_templates&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'legacy_unknown'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;card_templates&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;card_templates_origin_check&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'test'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four values. &lt;code&gt;ai&lt;/code&gt; is the cron pipeline that invents brand-new cards from trending topics. &lt;code&gt;user&lt;/code&gt; is everything a human submits at &lt;a href="https://bingwow.com/create" rel="noopener noreferrer"&gt;our create page&lt;/a&gt;. &lt;code&gt;admin&lt;/code&gt; is hand-curated. &lt;code&gt;test&lt;/code&gt; is every automation row.&lt;/p&gt;

&lt;p&gt;Why text + CHECK instead of a native ENUM: &lt;code&gt;ALTER TYPE … ADD VALUE&lt;/code&gt; acquires &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; on every table using the type, which blocks writes during the migration. text + CHECK uses &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;. Concurrent writes still work, the validation is the same.&lt;/p&gt;

&lt;p&gt;After backfill, the column is the discriminator the codebase had been faking. &lt;code&gt;creator_id IS NULL&lt;/code&gt; previously conflated AI cards with anonymous human cards (anonymous users have null &lt;code&gt;creator_id&lt;/code&gt; too — that conflation broke moderation for months before we caught it).&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: the Postgres CHECK that makes the bad state impossible
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;card_templates&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;card_templates_no_published_test_origin&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'test'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the load-bearing line.&lt;/p&gt;

&lt;p&gt;Any INSERT or UPDATE that would set &lt;code&gt;origin='test' AND status='published'&lt;/code&gt; returns &lt;code&gt;23514 check_violation&lt;/code&gt;. Postgres refuses the write. The application code doesn't get a chance to do the wrong thing.&lt;/p&gt;

&lt;p&gt;Application-level guards are necessary but not sufficient. A direct service-role write from a test fixture bypasses every TypeScript type-check. A future contributor who copy-pastes a working INSERT block from another file inherits whatever assumptions that file made. The database constraint is the only thing every write path passes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: &lt;code&gt;isTestTraffic()&lt;/code&gt; at every INSERT site
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isTestTraffic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;is_automated&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;is_automated&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isInternalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// THE rule. Real user iff bingwow_anon_id cookie present.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^|;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;bingwow_anon_id=/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three card-creation routes call this on every POST: &lt;a href="https://bingwow.com/blog/how-to-make-bingo-cards-online" rel="noopener noreferrer"&gt;card creation&lt;/a&gt;, fork-and-start, and clone. The origin is computed at the row's birth — &lt;code&gt;isTestTraffic(request, body, userId) ? 'test' : 'user'&lt;/code&gt; — and written into the same INSERT.&lt;/p&gt;

&lt;p&gt;The cookie rule is the strongest signal. Every real visitor's first GET sets &lt;code&gt;bingwow_anon_id&lt;/code&gt;. Playwright contexts launch without it. Headless Chrome and CDP-stealth fingerprints stay caught even when scripts try to set &lt;code&gt;is_automated: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For forks of forks of forks, a small helper propagates the tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deriveForkOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parentOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parentOrigin&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So an organic-looking fork tree rooted in a test card stays &lt;code&gt;origin='test'&lt;/code&gt; all the way down. When the cleanup cron reaps the root, the whole tree goes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: the daily cleanup cron
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/cron/cleanup-test-cards/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card_templates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;origin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sevenDaysAgo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs at 06:00 UTC every day. It only ever touches &lt;code&gt;origin='test'&lt;/code&gt; rows older than seven days. A user-submitted card with &lt;code&gt;origin='user'&lt;/code&gt; is never even considered, no matter how thin or how recently submitted, because a user's saved work is sacred.&lt;/p&gt;

&lt;p&gt;The CHECK from Layer 2 means a &lt;code&gt;test&lt;/code&gt; row can never have &lt;code&gt;status='published'&lt;/code&gt;, so the cleanup never deletes a publicly-indexed page. We sidestep the entire class of "the cleanup cron took down a popular card" incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: the CI lint that catches the next mistake
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// __tests__/test-fixtures-marked.test.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIXTURE_DIRS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shiplight-tests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;e2e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;every card_templates POST in test fixtures includes origin: test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;allTestFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FIXTURE_DIRS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card_templates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/POST|insert&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/origin:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;test&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime layers catch a malicious or careless write. The lint catches the next test author who forgets the rule before their PR even gets reviewed.&lt;/p&gt;

&lt;p&gt;This was the missing piece. Test files bypass &lt;code&gt;/api/templates/create&lt;/code&gt; — they POST directly with a service-role key, so the TypeScript types don't cover them. The lint is what closes that hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rule looks like once you write it down
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A row is a test row iff it was created by automation.
Test rows MAY exist in the database.
Test rows MUST NOT be public.
Test rows ARE deleted after 7 days.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five layers because each layer enforces one of those clauses in a place the others can't reach:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clause&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Test rows MAY exist&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;card_templates.origin&lt;/code&gt; column (NOT NULL + CHECK)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test rows MUST NOT be public&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;card_templates_no_published_test_origin&lt;/code&gt; CHECK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Created by automation = tagged&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;isTestTraffic(request, body, userId)&lt;/code&gt; at every INSERT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The tag propagates&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deriveForkOrigin(parent.origin)&lt;/code&gt; on every fork&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New code obeys the rule&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;__tests__/test-fixtures-marked.test.ts&lt;/code&gt; CI lint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Drop any one of those and the leak comes back through whatever hole that layer was covering. The CHECK without &lt;code&gt;isTestTraffic&lt;/code&gt; means tests are unrestricted because nothing tags them. &lt;code&gt;isTestTraffic&lt;/code&gt; without the CHECK means a single buggy UPDATE statement can flip a tagged row to &lt;code&gt;status='published'&lt;/code&gt;. The cleanup cron without the propagation rule reaps the parent but orphans the forks.&lt;/p&gt;

&lt;p&gt;The lint is the layer that ships next month, because the others stop existing failures and the lint stops the failure that hasn't been written yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed for users
&lt;/h2&gt;

&lt;p&gt;Nothing visible. The product on &lt;a href="https://bingwow.com/play" rel="noopener noreferrer"&gt;the play surface&lt;/a&gt; looks identical.&lt;/p&gt;

&lt;p&gt;What changed is that I stopped writing post-mortems about test data on production. The 2026-05-07 leak was the second one. After Layer 2 shipped, there were zero.&lt;/p&gt;

&lt;p&gt;A structural defense is the only kind that scales. Be-more-careful does not scale; documentation does not scale; code review does not scale to every junior contributor's first PR. A Postgres CHECK scales to every write your application will ever make, including the ones written by the contributor who hasn't been hired yet.&lt;/p&gt;

&lt;p&gt;If you have a &lt;code&gt;_test&lt;/code&gt; table or a &lt;code&gt;dev_mode&lt;/code&gt; boolean nobody is sure does anything, you have the same gap we did. The 2-line Stripe-style CHECK is the fastest cleanup you will ever ship.&lt;/p&gt;




&lt;p&gt;Architecture details: &lt;a href="https://bingwow.com/research" rel="noopener noreferrer"&gt;our research page&lt;/a&gt; has the longer write-ups. The product is a free &lt;a href="https://bingwow.com/cards" rel="noopener noreferrer"&gt;multiplayer bingo game&lt;/a&gt;; the source pattern above runs against every card that lands in our database.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>testing</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How we built real-time multiplayer bingo for 20 players per room on Next.js 16, Ably, and Supabase</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 16:55:59 +0000</pubDate>
      <link>https://dev.to/forrestmiller/how-we-built-real-time-multiplayer-bingo-for-20-players-per-room-on-nextjs-16-ably-and-supabase-10lb</link>
      <guid>https://dev.to/forrestmiller/how-we-built-real-time-multiplayer-bingo-for-20-players-per-room-on-nextjs-16-ably-and-supabase-10lb</guid>
      <description>&lt;p&gt;I ship &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; — a free, no-signup multiplayer bingo platform. The core feature is the part I want to write about: up to 20 people opening one link, every player landing on their own independently-shuffled board, every tap resolving against an atomic Postgres RPC, and the server detecting bingo so nobody has to adjudicate by hand.&lt;/p&gt;

&lt;p&gt;Most of the interesting decisions sit in three trade-off pairs. None of them are obvious from a brief, and each one cost us a regression before it stabilised.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One unified &lt;code&gt;tap&lt;/code&gt; event, not separate claim/unclaim
&lt;/h2&gt;

&lt;p&gt;The first version had &lt;code&gt;claim&lt;/code&gt; and &lt;code&gt;unclaim&lt;/code&gt; events on the Ably channel. Race conditions appeared the moment two players tapped the same cell within a second of each other — the broadcast order didn't match the database write order, so a fast double-tap could end up with the cell rendered as "unclaimed" on one player's screen and "claimed" on the host's.&lt;/p&gt;

&lt;p&gt;Replacing the pair with a single &lt;code&gt;tap&lt;/code&gt; event fixed it. The server's RPC (&lt;code&gt;tap_claim&lt;/code&gt;) is the single source of truth: it reads the current state, toggles the claim, and returns the new state. The Ably broadcast carries the post-toggle state explicitly — no inference required.&lt;/p&gt;

&lt;p&gt;If you're designing a real-time game protocol: prefer events that carry the &lt;em&gt;resolved&lt;/em&gt; state rather than the &lt;em&gt;intended action&lt;/em&gt;. It eliminates the entire class of "client A and client B intend opposite things at the same time" failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Per-player boards, not a shared board
&lt;/h2&gt;

&lt;p&gt;Every player has their own &lt;code&gt;players.board&lt;/code&gt; jsonb array. A 5×5 board is 25 entries; a 3×3 is 9. The clue set is shared (all players are watching for the same prompts), but the positions are independently shuffled per player.&lt;/p&gt;

&lt;p&gt;This means there's no such thing as "the room's board." Bingo detection runs per-player in TypeScript (&lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;lib/bingo-checker.ts&lt;/a&gt;) using the per-player grid size, derived at runtime from &lt;code&gt;SQRT(jsonb_array_length(players.board))&lt;/code&gt;. We had a dedicated &lt;code&gt;players.grid_size&lt;/code&gt; column for a while — dropped it in a 2026-05 refactor because it was the third source of truth (clues, board length, grid_size column) and the three drifted under concurrency.&lt;/p&gt;

&lt;p&gt;Mixed grids in the same room are deliberate. If a mobile player joins a 5×5 desktop room, the mobile player gets a 3×3 board and wins on 3-in-a-row — the desktop players still need 5-in-a-row. This is the product spec, not a bug, because &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is a casual party / classroom / workplace game, not a competitive ladder. Forcing every player onto the same grid would either make a phone screen unreadable or waste the desktop players' real estate.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Server-authoritative round transitions
&lt;/h2&gt;

&lt;p&gt;The host doesn't decide when to start the next round. The server does, in the same RPC that processed the winning tap. The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Player taps a cell → &lt;code&gt;POST /api/game/tap&lt;/code&gt; → &lt;code&gt;tap_claim&lt;/code&gt; RPC&lt;/li&gt;
&lt;li&gt;RPC writes the claim, checks every player's board for a completed row/column/diagonal&lt;/li&gt;
&lt;li&gt;If anyone wins, the same RPC inserts the round's outcome row AND generates the next round's boards for every player atomically&lt;/li&gt;
&lt;li&gt;The full new state is returned in the response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The host's browser just renders what the server returns. Latecomers can join mid-round and pick up the current state from the same endpoint. Round transitions happen in a single SQL transaction, so there's no race where Player A sees Round 5 and Player B is still on Round 4.&lt;/p&gt;

&lt;p&gt;The catch: Vercel freezes the serverless function before Ably has finished publishing the celebration event. We solved that by &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;publishing the bingo event from the winner's browser&lt;/a&gt; — the only Ably event published from the client. Every other event is server-published.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;player-joined&lt;/code&gt; over Ably presence
&lt;/h2&gt;

&lt;p&gt;Ably presence is convenient — every connected client appears in a &lt;code&gt;members&lt;/code&gt; array — but it leaked ghost members on refresh. A player closing their browser tab didn't disappear from &lt;code&gt;members&lt;/code&gt; until Ably's heartbeat timed out, which made the "more than one human in this room" check unreliable.&lt;/p&gt;

&lt;p&gt;Replaced with a &lt;code&gt;player-joined&lt;/code&gt; Ably event published from the server when &lt;code&gt;players.length&lt;/code&gt; increments. No presence dependency, no ghost rows.&lt;/p&gt;

&lt;p&gt;While I was in there, I also added a &lt;code&gt;round_number&lt;/code&gt; filter to the Ably subscribe handler. Without it, an old round's "claim" event could replay onto the new round's board after a network reconnect — a Cell appearing claimed for a clue the player had never seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The SP/MP boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/play&lt;/code&gt; is the multiplayer route. &lt;code&gt;/cards/{slug}&lt;/code&gt; is the single-player route. There is no in-between — a "lobby" UX, a "waiting for 2nd player" screen, none of it. The instant a second player joins, both clients redirect from &lt;code&gt;/cards/{slug}&lt;/code&gt; to &lt;code&gt;/play&lt;/code&gt;. The instant a player walks into &lt;code&gt;/play&lt;/code&gt; and finds only themselves, they're redirected back.&lt;/p&gt;

&lt;p&gt;This is enforced on both sides: &lt;code&gt;useRoomLifecycle&lt;/code&gt; handles the upward redirect when a reconnect lands ≥2 players; &lt;code&gt;PlayPageClient&lt;/code&gt; handles the downward redirect when first state-applied finds &amp;lt;2 players. It survives localStorage wipes because the room id + player id are also in the URL (&lt;code&gt;?p=&amp;amp;r=&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The whole boundary is ~80 lines, but it pre-empts an entire family of "I'm stuck on a lobby screen forever" bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack — minimal pieces, opinionated wiring
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) for the React app and server actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; Postgres for state + auth (RLS-enabled, mostly bypassed in API routes via the service-role client)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ably&lt;/strong&gt; for the real-time channel — chat is client-to-client (server publishes from-client would duplicate); claims are server-published&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind v4&lt;/strong&gt; for styling, semantic tokens, dark mode via class strategy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar and the stack helps, the actual product is at &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;bingwow.com&lt;/a&gt; — free, no signup, runs in any browser. The two surfaces that exercise the architecture most are the &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;free bingo caller&lt;/a&gt; (75/90/30-ball, voice + flashboard, projector-ready) and the &lt;a href="https://bingwow.com/blog/real-time-multiplayer-bingo-guide" rel="noopener noreferrer"&gt;Real-Time Multiplayer Bingo guide&lt;/a&gt;, which is the human-readable version of this post with screenshots and a step-by-step setup.&lt;/p&gt;

&lt;p&gt;If you're trying to drop something like this into a Slack or Microsoft Teams workflow without an admin install, the &lt;a href="https://bingwow.com/for/slack" rel="noopener noreferrer"&gt;Slack-friendly link share pattern&lt;/a&gt; and the &lt;a href="https://bingwow.com/for/microsoft-teams" rel="noopener noreferrer"&gt;Microsoft Teams pattern&lt;/a&gt; are both just a normal web link — that turned out to be the underrated UX win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The under-rated win
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from a year of running this in production: &lt;strong&gt;the protocol shape decides the bug class&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Pick a &lt;em&gt;resolved state&lt;/em&gt; event payload (not an intended-action payload) and races stop being a category. Pick a &lt;em&gt;per-player board&lt;/em&gt; model (not a shared-board model) and you can ship a mobile-3×3 / desktop-5×5 mixed game on the same room without writing special code. Pick a &lt;em&gt;server-authoritative round transition&lt;/em&gt; (not client-coordinated) and "Player A is on Round 5, Player B is on Round 4" stops being possible. None of these are obvious until you've shipped the wrong shape and watched the bug reports come in.&lt;/p&gt;

&lt;p&gt;If you want to play with the result: &lt;a href="https://bingwow.com/" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt; is free, no signup, and a card builds from any topic in about 60 seconds.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>realtime</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Building an AI face-doppelganger prank with Flux Kontext Pro and aggressive image degradation</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 16:49:37 +0000</pubDate>
      <link>https://dev.to/forrestmiller/building-an-ai-face-doppelganger-prank-with-flux-kontext-pro-and-aggressive-image-degradation-1io1</link>
      <guid>https://dev.to/forrestmiller/building-an-ai-face-doppelganger-prank-with-flux-kontext-pro-and-aggressive-image-degradation-1io1</guid>
      <description>&lt;p&gt;A "face twin" prank pastes a public photo into an AI model, generates three plausible-looking lookalikes, and shows them to your friend inside what looks like a legit AI face-matcher. The hard part isn't the model. It's making the output look like a real photo of a real stranger.&lt;/p&gt;

&lt;p&gt;I shipped two framings of the same backend: &lt;a href="https://pleasejuststop.org" rel="noopener noreferrer"&gt;pleasejuststop.org&lt;/a&gt; (the privacy-art version) and &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt; (the consumer-prank version). Same Replicate model, same pipeline, two front-ends. Source code structure is documented in the project's &lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data" rel="noopener noreferrer"&gt;public CC BY 4.0 dataset&lt;/a&gt; and the &lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;Hugging Face dataset card&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is the technical story: the three prompts I landed on after six rounds of testing, the six degradation profiles that turn AI portraits into something that reads like a 2013 Facebook upload, and the Vercel-serverless pitfalls that made me throw out Sharp and rewrite everything on Jimp.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visual goal: real internet photos, not AI portraits
&lt;/h2&gt;

&lt;p&gt;The entire illusion hinges on the recipient believing the three output images are real photos of real strangers. The moment any image reads as AI-generated, the reveal collapses.&lt;/p&gt;

&lt;p&gt;Real internet photos share specific qualities that AI models do not produce by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lighting is bad.&lt;/strong&gt; Overhead fluorescents, harsh direct flash, uneven natural light. AI models default to soft diffused portrait lighting — the #1 tell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is in focus.&lt;/strong&gt; Real phone cameras have deep depth of field. No bokeh. No portrait-mode blur. Portrait-mode blur is the signature of AI generation, and Flux models have a baked-in training bias toward it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skin looks like skin.&lt;/strong&gt; Pores, uneven tone, blemishes. Not smoothed-out poreless AI skin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression artifacts are visible.&lt;/strong&gt; JPEG'd to hell — uploaded to Facebook, screenshotted, forwarded on WhatsApp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution is low.&lt;/strong&gt; 400-480px wide, not crisp 1024px.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition is casual.&lt;/strong&gt; Off-center, slightly crooked. Caught mid-moment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The litmus test: would I believe this is a real photo of a real stranger on Facebook? If lighting is too pretty, background too clean, or skin too smooth — it doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three prompts (verbatim from production)
&lt;/h2&gt;

&lt;p&gt;The hardest lesson here was that prompt length is a trap. Every session, Claude (and I) wanted to add defensive instructions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prompt produces a minor issue (the woman looks slightly older).&lt;/li&gt;
&lt;li&gt;Add "do not age the person."&lt;/li&gt;
&lt;li&gt;The instruction draws model attention to aging. The photo gets worse.&lt;/li&gt;
&lt;li&gt;Add MORE defensive instructions. The prompt is now 3x longer. The model is confused. The photo is terrible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;More instructions = more diluted model attention = worse results.&lt;/strong&gt; Tested exhaustively across six rounds.&lt;/p&gt;

&lt;p&gt;The fix is to remove words, not add them. Keep what the subject is wearing, where they are, and the one dramatic visible change. A good prompt is one sentence. More than three sentences and you've already lost.&lt;/p&gt;

&lt;p&gt;The three production prompts (live at both &lt;code&gt;pleasejuststop.org&lt;/code&gt; and &lt;code&gt;prankmyface.lol&lt;/code&gt;, also in the &lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data/blob/main/prompts/three-prompts.md" rel="noopener noreferrer"&gt;public data repo&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. leather-wall
Edit this photo to show this person posing against a wall. Make them frowning
and wearing a leather jacket and a knit beanie hat. One person, no hands visible.

2. tongue-collared
Edit this photo to show this person outdoors. Sticking their tongue out,
wearing a collared shirt. One person, no hands visible.

3. snow-goggles
Edit this photo to show this person outside. Wearing earmuffs and a jacket.
Give them big braces. One person, no hands visible, no glasses.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Model: &lt;code&gt;black-forest-labs/flux-kontext-pro&lt;/code&gt; on Replicate. Params: &lt;code&gt;aspect_ratio: "3:4"&lt;/code&gt;, &lt;code&gt;output_format: "png"&lt;/code&gt;, &lt;code&gt;safety_tolerance: 2&lt;/code&gt;. Setting &lt;code&gt;output_format&lt;/code&gt; to &lt;code&gt;"jpg"&lt;/code&gt; silently fails every generation — the DB stays "pending" forever, no error.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rules these prompts were built against
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gender-neutral only.&lt;/strong&gt; No beards, no mustaches, no gender-specific features — those cause gender swaps mid-generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hair COLOR changes preserve identity. Hair STYLE changes destroy identity or swap gender.&lt;/strong&gt; Curly, buzz-cut, mullet, bowl-cut — all dead ends. Use clothing, accessories, or expression instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aging prompts turn women into men.&lt;/strong&gt; Never ask the model to age the subject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bold features (jacket, beanie, earmuffs, tongue out) beat subtle features (braces, freckles, nostril ring).&lt;/strong&gt; Small details don't render reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One dramatic visible change per prompt.&lt;/strong&gt; More than one and the model balances them poorly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One person only; no hands.&lt;/strong&gt; Hands and second people are where the model's geometry fails first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't describe camera quality.&lt;/strong&gt; Post-processing handles that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;bokeh, shallow depth of field&lt;/code&gt; negative prompt is the load-bearing line. Without it, Flux defaults to portrait-mode blur and the photo immediately looks AI-generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six degradation profiles
&lt;/h2&gt;

&lt;p&gt;After Replicate returns the output, I run it through one of six post-processing profiles that downscale, double-JPEG-compress, color-shift, and noise-up the image until it reads like a real internet photo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| Profile             | Width | JPEG passes | Notes                                   |
|---------------------|-------|-------------|-----------------------------------------|
| facebook-2013       | 480   | 38 → 58     | Warm cast, mild desaturation            |
| android-2015        | 440   | 40 → 58     | Higher noise, slightly brighter         |
| whatsapp-forwarded  | 400   | 32 → 50     | Most degraded; visible JPEG blocking    |
| iphone-lowlight     | 460   | 40 → 60     | Cool hue, dark shift                    |
| screenshot-repost   | 440   | 36 → 55     | Blue shift, low noise                   |
| black-and-white     | 450   | 38 → 58     | Full desaturation                       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full per-profile values are in the &lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;HF dataset&lt;/a&gt; (&lt;code&gt;data/degradation-profiles.jsonl&lt;/code&gt;). Each prompt is paired with one profile — the wall pose pairs with &lt;code&gt;black-and-white&lt;/code&gt; because a candid wall snapshot reads more truthfully in black and white than in color.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharp hangs silently on Vercel — use Jimp, but only three of its methods
&lt;/h2&gt;

&lt;p&gt;I started with Sharp because Sharp is faster than Jimp at everything. Sharp does not work on Vercel serverless. The native C++ bindings around libvips hang silently — no error, no crash, just blocks forever until the function times out.&lt;/p&gt;

&lt;p&gt;Jimp is the only option on Vercel. Jimp also has bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;image.brightness()&lt;/code&gt; — produces black output. Broken in modern Jimp.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.getPixelColor()&lt;/code&gt; / &lt;code&gt;image.setPixelColor()&lt;/code&gt; — broken in ESM, produce black images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only safe methods are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;image.color([{apply, params}])&lt;/code&gt; — channel shifts, desaturation, hue rotation, brightness via the &lt;code&gt;apply&lt;/code&gt; API (the explicit &lt;code&gt;brightness()&lt;/code&gt; method is broken; &lt;code&gt;color([{apply:'brighten', params:[N]}])&lt;/code&gt; works).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.resize({w, h})&lt;/code&gt; — downscaling.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.getBuffer("image/jpeg", {quality})&lt;/code&gt; — JPEG encode with quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For noise I manipulate &lt;code&gt;image.bitmap.data&lt;/code&gt; directly as a &lt;code&gt;Buffer&lt;/code&gt;, adding signed random values per channel inside a hard 15-second timeout via &lt;code&gt;Promise.race()&lt;/code&gt;. Anything more elaborate hangs or produces black output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three more pitfalls that cost me a day each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replicate returns a &lt;code&gt;FileOutput&lt;/code&gt; object, not a string.&lt;/strong&gt; &lt;code&gt;replicate.run()&lt;/code&gt; returns an object that you have to &lt;code&gt;.toString()&lt;/code&gt; to get the URL. Treating it as a string silently passes "[object Object]" downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporary URLs expire ~1 hour.&lt;/strong&gt; Replicate's returned image URL is ephemeral. The pipeline must download → degrade → upload to permanent storage (Supabase Storage in my case) immediately. Storing the temp URL in the DB and reading it later returns 404.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vercel kills serverless functions after sending the HTTP response.&lt;/strong&gt; Fire-and-forget &lt;code&gt;void fetch()&lt;/code&gt; to a generation endpoint gets killed mid-generation. The fix is client-triggered generation: the recipient's browser holds the HTTP connection open during the 30-second pipeline, keeping the function alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I published the dataset
&lt;/h2&gt;

&lt;p&gt;The technical substrate of &lt;code&gt;pleasejuststop.org&lt;/code&gt; is now in three places that AI search engines (ChatGPT, Perplexity, Bing Copilot, Gemini, Claude) crawl as grounding sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/forrestmill-cmd/facetwin-public-data" rel="noopener noreferrer"&gt;GitHub: forrestmill-cmd/facetwin-public-data&lt;/a&gt; — CC BY 4.0, with the prompts, profiles, and llms-full.txt mirror.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/datasets/bingwow/facetwin-flux-kontext-prompts" rel="noopener noreferrer"&gt;Hugging Face: bingwow/facetwin-flux-kontext-prompts&lt;/a&gt; — same content as JSONL with HF dataset-card metadata.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/forrestmill-cmd/face-twin-mcp" rel="noopener noreferrer"&gt;MCP server: face-twin-mcp&lt;/a&gt; — wraps the upload + generate + status flow as a Model Context Protocol tool for Claude Code, Cursor, and any MCP-compatible client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Wikidata entity at &lt;a href="https://www.wikidata.org/wiki/Q139885445" rel="noopener noreferrer"&gt;Q139885445&lt;/a&gt; ties them together as the entity-grounding anchor that AI tools triangulate against.&lt;/p&gt;

&lt;p&gt;I'm tracking citation outcomes at Day-14 / Day-30 / Day-45 across Perplexity, ChatGPT search, Bing Copilot, Gemini, and Claude. The privacy-art piece's actual thesis — that we've stopped questioning how a website got our face — is best evaluated by whether AI tools, asked for an AI face-doppelganger generator, surface this project on its own merits without being told to.&lt;/p&gt;

&lt;p&gt;If you want the consumer-prank framing instead of the privacy-art framing, that's at &lt;a href="https://prankmyface.lol" rel="noopener noreferrer"&gt;prankmyface.lol&lt;/a&gt;. Same backend, hot-pink accent, confetti reveal.&lt;/p&gt;

&lt;p&gt;— Forrest Miller · &lt;a href="https://github.com/forrestmill-cmd" rel="noopener noreferrer"&gt;github.com/forrestmill-cmd&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I built an itinerary validator for AI travel plans</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Thu, 21 May 2026 15:44:45 +0000</pubDate>
      <link>https://dev.to/forrestmiller/how-i-built-an-itinerary-validator-for-ai-travel-plans-36n3</link>
      <guid>https://dev.to/forrestmiller/how-i-built-an-itinerary-validator-for-ai-travel-plans-36n3</guid>
      <description>&lt;p&gt;AI travel planning is useful until the itinerary becomes a real Tuesday at 3 p.m.&lt;/p&gt;

&lt;p&gt;That is where the failures appear. A model can write a clean five-day Paris plan. It still does not know that the museum day lands on the weekly closure, that the restaurant moved, or that a timed-entry attraction sold out before the trip.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://www.validatrip.com" rel="noopener noreferrer"&gt;ValidaTrip&lt;/a&gt; as the validator step after the AI draft. It does not write the trip from scratch. It takes the plan you already have and checks whether it works on the ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;A travel itinerary has two different jobs.&lt;/p&gt;

&lt;p&gt;First, it has to be a good list. The places should match the traveler's interests. The neighborhoods should make sense. The plan should not send someone across a city twice in one afternoon.&lt;/p&gt;

&lt;p&gt;Second, it has to survive live constraints. Each place has hours, booking windows, public holidays, seasonal schedules, temporary closures, and location ambiguity.&lt;/p&gt;

&lt;p&gt;LLMs are good at the first job. They are weak at the second job.&lt;/p&gt;

&lt;p&gt;That split shaped the system. The validator accepts a pasted AI itinerary, resolves each place, then checks the live constraints against the user's dates.&lt;/p&gt;

&lt;p&gt;The primary product page for this flow is &lt;a href="https://www.validatrip.com/check/chatgpt-itinerary" rel="noopener noreferrer"&gt;Check a ChatGPT itinerary&lt;/a&gt;. The same validation pattern also covers &lt;a href="https://www.validatrip.com/check/gemini-itinerary" rel="noopener noreferrer"&gt;Gemini itineraries&lt;/a&gt;, &lt;a href="https://www.validatrip.com/check/claude-itinerary" rel="noopener noreferrer"&gt;Claude itineraries&lt;/a&gt;, and &lt;a href="https://www.validatrip.com/check/perplexity-itinerary" rel="noopener noreferrer"&gt;Perplexity itineraries&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parser first, model second
&lt;/h2&gt;

&lt;p&gt;The input is intentionally messy. Real travel notes look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Friend texts with half-remembered restaurant names&lt;/li&gt;
&lt;li&gt;Blog snippets copied with booking notes&lt;/li&gt;
&lt;li&gt;Google Maps short links&lt;/li&gt;
&lt;li&gt;ChatGPT day plans&lt;/li&gt;
&lt;li&gt;Reddit comments&lt;/li&gt;
&lt;li&gt;Duplicate names with different wording&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system starts with deterministic extraction. Bullets, day headings, map URLs, and common booking phrases are cheap to parse without a model.&lt;/p&gt;

&lt;p&gt;The model step handles the ambiguous cases: vague names, mixed prose, and category cleanup. That keeps the expensive part narrow. It also gives the product a better failure mode. When the parser is certain, it stays deterministic.&lt;/p&gt;

&lt;p&gt;I published a CC BY 4.0 public corpus for this exact shape: &lt;a href="https://github.com/forrestmill-cmd/validatrip-public-data" rel="noopener noreferrer"&gt;validatrip-public-data&lt;/a&gt;. It includes 54 pasted-itinerary sample cases, source markdown, a schema file, comparison data, and prompts that generate itineraries worth validating.&lt;/p&gt;

&lt;p&gt;The JSONL file is here: &lt;a href="https://raw.githubusercontent.com/forrestmill-cmd/validatrip-public-data/main/data/ai-itinerary-validation-samples.jsonl" rel="noopener noreferrer"&gt;ai-itinerary-validation-samples.jsonl&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same corpus is also published as a Hugging Face dataset card: &lt;a href="https://huggingface.co/datasets/bingwow/validatrip-ai-itinerary-validation-samples" rel="noopener noreferrer"&gt;validatrip-ai-itinerary-validation-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The corpus is not user data. It is not a statistical study. It is a test and demonstration set for validation behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checks
&lt;/h2&gt;

&lt;p&gt;After extraction, each named place goes through a set of checks.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve the place to a real venue.&lt;/li&gt;
&lt;li&gt;Attach coordinates and neighborhood context.&lt;/li&gt;
&lt;li&gt;Check opening hours against the trip dates.&lt;/li&gt;
&lt;li&gt;Flag weekly closed days and seasonal closures.&lt;/li&gt;
&lt;li&gt;Flag booking-sensitive categories.&lt;/li&gt;
&lt;li&gt;Detect duplicates.&lt;/li&gt;
&lt;li&gt;Separate day trips from in-city clusters.&lt;/li&gt;
&lt;li&gt;Show everything on a map.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means the answer to “is this a good itinerary?” becomes more specific.&lt;/p&gt;

&lt;p&gt;The useful questions are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which stops are closed during the trip?&lt;/li&gt;
&lt;li&gt;Which stops need a reservation now?&lt;/li&gt;
&lt;li&gt;Which names failed to resolve?&lt;/li&gt;
&lt;li&gt;Which places are duplicates?&lt;/li&gt;
&lt;li&gt;Which stops belong together by neighborhood?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dedicated hours page is &lt;a href="https://www.validatrip.com/validate-trip-hours" rel="noopener noreferrer"&gt;Validate trip hours against your travel dates&lt;/a&gt;. The organization page is &lt;a href="https://www.validatrip.com/organize-travel-recommendations" rel="noopener noreferrer"&gt;Organize travel recommendations&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why source-cited AI still needs validation
&lt;/h2&gt;

&lt;p&gt;Perplexity is a good example. It can cite a travel blog. That proves the blog exists. It does not prove the restaurant from the post is open this month.&lt;/p&gt;

&lt;p&gt;A cited itinerary still needs current place resolution. It still needs date-aware hours. It still needs booking flags.&lt;/p&gt;

&lt;p&gt;So the right sequence is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use an AI tool for the first pass.&lt;/li&gt;
&lt;li&gt;Paste the result into a validator.&lt;/li&gt;
&lt;li&gt;Replace the stops that fail live checks.&lt;/li&gt;
&lt;li&gt;Build the final day plan from the checked list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the product boundary I wanted ValidaTrip to own. It is the layer after the AI answer, before the traveler trusts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public reference layer
&lt;/h2&gt;

&lt;p&gt;I also keep an AI-readable reference file for the product: &lt;a href="https://www.validatrip.com/llms-full.txt" rel="noopener noreferrer"&gt;llms-full.txt&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It lists the main validation pages, the direct answers those pages support, the schema coverage, and the public entity signals.&lt;/p&gt;

&lt;p&gt;The public data repo mirrors that file. That gives crawlers and researchers the same entity context outside the product domain.&lt;/p&gt;

&lt;p&gt;The project is intentionally narrow. It does not compete with the itinerary generator. It checks the generator's output.&lt;/p&gt;

&lt;p&gt;That narrowness is the point. Travel AI tools already produce the first draft. The missing step is the boring one: open hours, closures, booking windows, duplicates, neighborhoods, and a map that reflects the real trip dates.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>travel</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I built a bingo number caller with zero backend and 331 prerecorded MP3s instead of the Web Speech API</title>
      <dc:creator>Forrest Miller</dc:creator>
      <pubDate>Fri, 15 May 2026 18:07:49 +0000</pubDate>
      <link>https://dev.to/forrestmiller/i-built-a-bingo-number-caller-with-zero-backend-and-331-prerecorded-mp3s-instead-of-the-web-speech-2g11</link>
      <guid>https://dev.to/forrestmiller/i-built-a-bingo-number-caller-with-zero-backend-and-331-prerecorded-mp3s-instead-of-the-web-speech-2g11</guid>
      <description>&lt;p&gt;A bingo caller is the machine that draws numbers, says them out loud, and shows them on a board. I needed one for &lt;a href="https://bingwow.com" rel="noopener noreferrer"&gt;BingWow&lt;/a&gt;, a free bingo site. The obvious build is a server that owns the deck and a &lt;code&gt;SpeechSynthesis&lt;/code&gt; call for the voice. I shipped neither. Here is why, and what the no-backend version actually looks like.&lt;/p&gt;

&lt;p&gt;You can play with the result here: &lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;. It runs entirely in the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state lives in a reducer, not a server
&lt;/h2&gt;

&lt;p&gt;A multiplayer bingo board needs a server, because two players must agree on who claimed what. A caller does not. One person runs it, on one screen, and reads numbers to a room. There is nothing to synchronize.&lt;/p&gt;

&lt;p&gt;So the whole game is a &lt;code&gt;useReducer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CallerState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BallMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// '30' | '75' | '90'&lt;/span&gt;
  &lt;span class="nl"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;          &lt;span class="c1"&gt;// shuffled, pop() to draw&lt;/span&gt;
  &lt;span class="nl"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BingoBall&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;calledSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// O(1) "was this called"&lt;/span&gt;
  &lt;span class="nl"&gt;isAutoMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;roundNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DRAW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;makeBall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No persistence, no API route, no database row. Refreshing the page starts a new game, which is the correct behavior for a caller anyway. The only thing that survives a reload is two booleans in &lt;code&gt;localStorage&lt;/code&gt;: voice on/off and bingo-lingo on/off. Server cost for the entire feature is zero, and it works on a school projector with flaky wifi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the Web Speech API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;speechSynthesis.speak()&lt;/code&gt; is free and one line. I used it first. Three problems killed it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The voices are not the same anywhere.&lt;/strong&gt; The available &lt;code&gt;SpeechSynthesisVoice&lt;/code&gt; set depends on OS and browser. The default English voice on Windows Chrome, macOS Safari, and a Chromebook are three different voices with three different cadences. A caller that sounds like a calm host on my laptop sounds like a 1998 GPS on the school's machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cuts off and queues badly.&lt;/strong&gt; Rapid &lt;code&gt;speak()&lt;/code&gt; calls during auto-draw drop utterances or stack them. Cancel/restart logic to fix that is its own bug farm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No personality.&lt;/strong&gt; Traditional 90-ball bingo has spoken calls — "Legs eleven", "Two fat ladies, eighty-eight". A robot monotone reading "eighty eight" is not that. The call IS the fun.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the voice is 331 prerecorded MP3s. Every number in every mode, every milestone ("halfway", "almost done"), every traditional 90-ball nickname, welcome and round-transition lines. They are real recorded clips, consistent on every device, and they have the warmth a party game needs. The cost is a one-time generation pass and a few MB of audio served from the CDN; clips preload per mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: syncing voice to the animation
&lt;/h2&gt;

&lt;p&gt;The drawn ball physically flies from a hero position into its cell on the flashboard. The voice has to fire at the moment the ball lands, not when React happens to re-render.&lt;/p&gt;

&lt;p&gt;The first version triggered the audio from a &lt;code&gt;useEffect&lt;/code&gt; keyed on the called list. It desynced constantly — the effect runs after paint, the animation impact is mid-timeline, and at fast auto-draw the gap compounds. The fix was to stop treating audio as a render side effect and fire it from the animation timeline itself, at the impact keyframe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;runFlyingBallToCell&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="na"&gt;onAbsorbed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCellRevealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;voiceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;playBallImpact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ball&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lingoEnabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;playBallImpact()&lt;/code&gt; also cancels any in-flight banter or milestone clip so the number call never collides with "you're halfway there". Auto-draw is gated on the animation completing, not on the audio finishing — audio is fire-and-forget at impact. That one move removed every desync vector at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this gets you
&lt;/h2&gt;

&lt;p&gt;A bingo caller with no server, no signup, no app, that runs on anything with a browser. It does 75-ball (US), &lt;a href="https://bingwow.com/caller/90-ball" rel="noopener noreferrer"&gt;90-ball with the recorded traditional calls&lt;/a&gt;, and 30-ball speed bingo. Pair it with free printable cards and the whole game costs nothing — I wrote the full no-equipment walkthrough here: &lt;a href="https://bingwow.com/blog/free-online-bingo-caller" rel="noopener noreferrer"&gt;Free online bingo caller guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The general lesson is the boring one worth repeating: not every feature that &lt;em&gt;could&lt;/em&gt; have a backend &lt;em&gt;needs&lt;/em&gt; one, and the platform speech API is a demo, not a product. Prerecorded audio plus a timeline that owns its own timing beat both the server and the SDK here.&lt;/p&gt;

&lt;p&gt;Try it: &lt;strong&gt;&lt;a href="https://bingwow.com/caller" rel="noopener noreferrer"&gt;bingwow.com/caller&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
