<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Travel Vient Blog</title><description>Build logs, technical tutorials, and lessons learned shipping indie software.</description><link>https://travelvient.com/</link><language>en-us</language><image><url>https://travelvient.com/og-default.png</url><title>Travel Vient Blog</title><link>https://travelvient.com/</link></image><item><title>75 Destinations Ranked by Daily Budget: 2026 Data</title><link>https://travelvient.com/blog/75-destinations-daily-cost-ranking-2026/</link><guid isPermaLink="true">https://travelvient.com/blog/75-destinations-daily-cost-ranking-2026/</guid><description>Maui costs 8.5x Ho Chi Minh City per day. Every US city in the dataset sits in the top third. The full ranking, the chart, and why the daily number lies.</description><pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I started planning a beach trip this summer. Pulled up the destination data on my site to compare options. Maui was sitting at $170 a day. Cancun was $45. Cartagena was $40. The number that caught me was not the cheapest, it was the gap. A week in Maui at the budget tier runs as much as four weeks in Cartagena. Same beach archetype, four times the spend.&lt;/p&gt;
&lt;p&gt;That sent me down the ranking. 75 destinations, sorted by daily budget cost in 2026. The cheapest is 12% of the most expensive.&lt;/p&gt;
&lt;h2 id=&quot;how-i-built-the-dataset&quot;&gt;How I built the dataset&lt;/h2&gt;
&lt;p&gt;The site has structured JSON files for 75 destinations at &lt;a href=&quot;https://travelvient.com/destinations/&quot;&gt;travelvient.com&lt;/a&gt;. Each file includes a &lt;code&gt;typicalCosts&lt;/code&gt; object with three tiers: &lt;code&gt;budgetPerDayUsd&lt;/code&gt;, &lt;code&gt;midrangePerDayUsd&lt;/code&gt;, and &lt;code&gt;luxuryPerDayUsd&lt;/code&gt;. The budget tier is what most cost-of-travel guides quote. It assumes a single traveler staying in hostels or budget guesthouses, eating local food, using public transit, and doing free or cheap activities.&lt;/p&gt;
&lt;p&gt;For each destination I read the budget field, sorted ascending, and grouped by continent. The data was last updated between April 23 and May 4, 2026. Full CSV: &lt;a href=&quot;https://travelvient.com/data/75-destinations-daily-cost-ranking-2026.csv&quot;&gt;75-destinations-daily-cost-ranking-2026.csv&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A few things this number does NOT capture, and they matter more than the ranking:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No airfare.&lt;/strong&gt; The biggest fixed cost of any trip is not in this dataset.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Season variance.&lt;/strong&gt; Maui in April is not Maui in December. The number is a year-round midpoint, not a peak or shoulder season quote.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single-traveler baseline.&lt;/strong&gt; Couples and families do not scale linearly per person. A $90 hotel room is $45 per person when split, but a $40 hostel bunk is still $40.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exchange-rate snapshot.&lt;/strong&gt; These numbers shift quarterly with currency moves, especially in Argentina, Turkey, and Japan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hostel-friendly assumption.&lt;/strong&gt; Cities where the budget tier assumes a hostel ($25 Bangkok) compare unfairly with cities where the budget tier assumes a 2-star hotel because no hostel scene exists ($110 Charleston).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your trip style does not match the budget-tier assumption, your daily spend will be higher. Most travelers’ actual spending sits closer to the midrange tier, which has a median of $160 per day across the same 75 cities.&lt;/p&gt;
&lt;h2 id=&quot;the-full-ranking&quot;&gt;The full ranking&lt;/h2&gt;
&lt;p&gt;Bar width shows daily budget in USD. Color shows continent.&lt;/p&gt;
&lt;div class=&quot;data-chart-legend&quot;&gt;&lt;span style=&quot;display:inline-flex;align-items:center;gap:0.375rem;&quot;&gt;&lt;span style=&quot;display:inline-block;width:0.75rem;height:0.75rem;border-radius:0.125rem;background:#06b6d4;&quot;&gt;&lt;/span&gt;Asia&lt;/span&gt;&lt;span style=&quot;display:inline-flex;align-items:center;gap:0.375rem;&quot;&gt;&lt;span style=&quot;display:inline-block;width:0.75rem;height:0.75rem;border-radius:0.125rem;background:#ef4444;&quot;&gt;&lt;/span&gt;Africa&lt;/span&gt;&lt;span style=&quot;display:inline-flex;align-items:center;gap:0.375rem;&quot;&gt;&lt;span style=&quot;display:inline-block;width:0.75rem;height:0.75rem;border-radius:0.125rem;background:#10b981;&quot;&gt;&lt;/span&gt;South America&lt;/span&gt;&lt;span style=&quot;display:inline-flex;align-items:center;gap:0.375rem;&quot;&gt;&lt;span style=&quot;display:inline-block;width:0.75rem;height:0.75rem;border-radius:0.125rem;background:#8b5cf6;&quot;&gt;&lt;/span&gt;Europe&lt;/span&gt;&lt;span style=&quot;display:inline-flex;align-items:center;gap:0.375rem;&quot;&gt;&lt;span style=&quot;display:inline-block;width:0.75rem;height:0.75rem;border-radius:0.125rem;background:#ec4899;&quot;&gt;&lt;/span&gt;Middle East&lt;/span&gt;&lt;span style=&quot;display:inline-flex;align-items:center;gap:0.375rem;&quot;&gt;&lt;span style=&quot;display:inline-block;width:0.75rem;height:0.75rem;border-radius:0.125rem;background:#6366f1;&quot;&gt;&lt;/span&gt;Oceania&lt;/span&gt;&lt;span style=&quot;display:inline-flex;align-items:center;gap:0.375rem;&quot;&gt;&lt;span style=&quot;display:inline-block;width:0.75rem;height:0.75rem;border-radius:0.125rem;background:#f59e0b;&quot;&gt;&lt;/span&gt;North America&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;data-chart&quot; role=&quot;list&quot; aria-label=&quot;75 destinations ranked from cheapest to most expensive by daily budget cost in USD&quot;&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 11.8%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Ho Chi Minh City&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$20&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 14.7%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Chiang Mai&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$25&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 14.7%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Hanoi&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$25&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 17.6%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Bangkok&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$30&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 17.6%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Oaxaca&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$30&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 20.6%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Marrakech&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#ef4444;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$35&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 20.6%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Medellin&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#10b981;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$35&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 23.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cairo&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#ef4444;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$40&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 23.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cartagena&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#10b981;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$40&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 23.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Krakow&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$40&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 23.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Mexico City&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$40&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 23.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Tbilisi&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$40&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 26.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Bali&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$45&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 26.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Buenos Aires&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#10b981;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$45&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 26.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cancun&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$45&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 28.2%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cozumel&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$48&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 29.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Istanbul&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$50&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 29.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Lima&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#10b981;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$50&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 29.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Nassau&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$50&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 29.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Prague&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$50&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 32.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Athens&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$55&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 32.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Budapest&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$55&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 32.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Costa Rica&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$55&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 32.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Osaka&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$55&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 32.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Seoul&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$55&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 32.4%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Vancouver&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$55&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 35.3%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Berlin&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$60&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 35.3%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Kyoto&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$60&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 35.3%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Porto&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$60&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 38.2%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cabo San Lucas&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$65&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 38.2%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Hong Kong&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$65&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 38.2%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Taipei&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$65&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 41.2%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Dubai&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#ec4899;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$70&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 41.2%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;London&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$70&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 41.2%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Portland&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$70&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 44.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Edinburgh&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$75&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 44.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Lisbon&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$75&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 44.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Rome&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$75&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 44.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Singapore&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$75&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 44.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Sydney&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#6366f1;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$75&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 44.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Tokyo&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#06b6d4;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$75&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 44.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Washington&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$75&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Barcelona&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cape Town&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#ef4444;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Copenhagen&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Denver&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Florence&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Madrid&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;San Juan&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 47.1%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Santorini&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$80&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 50.0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Boston&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$85&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 50.0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Dublin&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$85&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 50.0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;New Orleans&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$85&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 50.0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Orlando&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$85&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 52.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Austin&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$90&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 52.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Dubrovnik&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$90&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 52.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Paris&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$90&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 52.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Vienna&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$90&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 55.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Amsterdam&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$95&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 55.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Nashville&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$95&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 55.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Seattle&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$95&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 55.9%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Venice&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$95&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 58.8%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Chicago&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$100&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 58.8%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Las Vegas&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$100&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 58.8%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;San Diego&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$100&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 58.8%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;St. Thomas&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$100&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 64.7%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Charleston&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$110&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 64.7%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Los Angeles&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$110&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 64.7%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Miami&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$110&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 70.6%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;New York City&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$120&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 70.6%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Reykjavik&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#8b5cf6;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$120&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 73.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Grand Cayman&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$125&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 76.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Honolulu&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$130&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 76.5%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;San Francisco&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$130&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row&quot; role=&quot;listitem&quot; style=&quot;--bar: 100.0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Maui&lt;/span&gt;&lt;span class=&quot;bar&quot; style=&quot;background:#f59e0b;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;$170&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;h2 id=&quot;the-cheap-end-is-geographically-narrow&quot;&gt;The cheap end is geographically narrow&lt;/h2&gt;
&lt;p&gt;The 10 cheapest destinations are all in three regions: Southeast Asia (Ho Chi Minh City, Chiang Mai, Hanoi, Bangkok), Latin America (Oaxaca, Medellin, Cartagena, Mexico City), and one North African and Eastern European entry (Marrakech and Krakow). That is the entire bottom of the chart. Not Western Europe, not the Caribbean, not North America outside Mexico, not Oceania.&lt;/p&gt;
&lt;p&gt;Ho Chi Minh City sits at $20 per day. Maui sits at $170. The ratio is 8.5x. A traveler doing a budget-tier 14-night trip would spend $280 in Ho Chi Minh City and $2,380 in Maui, before flights.&lt;/p&gt;
&lt;p&gt;The Southeast Asian numbers are also striking because they cluster so tightly. Five Vietnamese and Thai cities span $20 to $30 a day. That is the same range you would pay for a single sit-down dinner in Reykjavik.&lt;/p&gt;
&lt;h2 id=&quot;us-cities-pile-up-at-the-top&quot;&gt;US cities pile up at the top&lt;/h2&gt;
&lt;p&gt;Of 19 US destinations in the dataset, 18 sit above the global median of $85 per day. The single exception is Portland at $70, which is still above 60% of the global ranking. Washington DC at $75 is the only other US city under $80.&lt;/p&gt;
&lt;p&gt;Hawaii is the outlier even by US standards. Maui at $170 and Honolulu at $130 both clear San Francisco ($130) and New York City ($120). For most of the US ranking the pattern is: smaller-market mainland cities ($70 to $95), mid-tier coastal cities ($100 to $110), and the truly expensive few ($120 to $170). Hawaii dominates the top.&lt;/p&gt;
&lt;p&gt;The Caribbean entries break similarly. Grand Cayman ($125), St. Thomas ($100), Nassau ($50), San Juan ($80). The spread inside a single region of beach destinations is 2.5x.&lt;/p&gt;
&lt;h2 id=&quot;europe-splits-east-versus-north&quot;&gt;Europe splits east versus north&lt;/h2&gt;
&lt;p&gt;Europe’s 24 destinations range from Krakow at $40 to Reykjavik at $120. The cheapest 6 are all Eastern or Southern European: Krakow ($40), Tbilisi ($40), Istanbul ($50), Prague ($50), Athens ($55), Budapest ($55). The most expensive 4 are all Northern: Copenhagen ($80), Amsterdam ($95), Reykjavik ($120), and Paris ($90) for the Western anchor.&lt;/p&gt;
&lt;p&gt;For a European trip, the daily-cost arithmetic alone says go east. Krakow at $40 is one-third the cost of Reykjavik at $120, in a continent where flight differences between the two are often under $100.&lt;/p&gt;
&lt;h2 id=&quot;the-honest-counterpoint&quot;&gt;The honest counterpoint&lt;/h2&gt;
&lt;p&gt;The daily-cost number is the easiest metric to publish and the worst metric to plan a trip with. The reason is fixed costs.&lt;/p&gt;
&lt;p&gt;I am sitting here looking at a beach trip from the US this summer. Maui at $170 a day looks brutal. Ho Chi Minh City at $20 a day looks like paradise on the spreadsheet. The math at one week:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Maui, 7 nights at budget tier: $1,190. Round-trip flights from the US West Coast for two people: roughly $1,000 to $1,400. Total: ~$2,400.&lt;/li&gt;
&lt;li&gt;Ho Chi Minh City, 7 nights at budget tier: $140. Round-trip flights from the US West Coast for two people: roughly $2,800 to $3,800. Total: ~$3,200, plus visas and time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a week, the cheap destination is not cheaper. The flight is the trip’s largest line item, and a 22-hour journey each way burns a day of the trip on either side. The Maui math gets ugly. The Ho Chi Minh City math is worse.&lt;/p&gt;
&lt;p&gt;Now run it at three weeks. The flight cost holds flat. The daily cost compounds. Three weeks in Maui hits $3,570 plus flights. Three weeks in Ho Chi Minh City hits $420 plus the same fixed cost. Now Southeast Asia is half the trip cost.&lt;/p&gt;
&lt;p&gt;This is the trap of the daily-budget metric. It looks like a comparison and it is actually a function of trip length. Anyone publishing a $/day number and not stating the assumed trip length is selling you on a number that flips depending on your itinerary.&lt;/p&gt;
&lt;p&gt;If you live in the US and you want a beach trip in summer, the daily-cost ranking is misleading. You are not choosing between Maui and Hanoi at the same trip length. You are choosing between a 6-hour flight to a $170-a-day destination and a 22-hour flight to a $20-a-day destination, and the only way the second option wins is if you stay long enough for the daily savings to absorb both the flight cost and the lost vacation days at each end. For most US travelers with two weeks of PTO, Hawaii wins on math.&lt;/p&gt;
&lt;h2 id=&quot;what-i-actually-do-now&quot;&gt;What I actually do now&lt;/h2&gt;
&lt;p&gt;When I look at the destination chart, I read three numbers, not one. The daily cost. The likely round-trip flight from where I live. And the trip length I can realistically take.&lt;/p&gt;
&lt;p&gt;For a 7-day trip from the US, the cheap-daily destinations rarely beat the expensive-daily destinations once flights are in. The exception is a deep value where the daily number is so low that even short trips justify the airfare (Cancun, Cartagena, Mexico City from most of the US).&lt;/p&gt;
&lt;p&gt;For 14-day trips, the math becomes a coin flip and personal preference dominates.&lt;/p&gt;
&lt;p&gt;For 21-day-plus trips, the cheap-daily destinations dominate. This is why long-term travelers and digital nomads cluster in Chiang Mai and Medellin and Bali. The fixed cost is fully amortized and the daily savings stack.&lt;/p&gt;
&lt;p&gt;If you want to compare specific cities, I keep the full guide set at &lt;a href=&quot;https://travelvient.com/destinations/&quot;&gt;/destinations/&lt;/a&gt;. The destination page for each city shows the budget, midrange, and luxury tiers side by side, plus the season-by-season crowd and weather data. Useful for the daily picture. Not a substitute for pricing your actual flights.&lt;/p&gt;
&lt;h2 id=&quot;what-id-want-to-analyze-next&quot;&gt;What I’d want to analyze next&lt;/h2&gt;
&lt;p&gt;The data essay that would actually answer the question I am asking: a flight-included total cost ranking. Three columns: 7-day total from a major US hub, 14-day total, 21-day total. The ranking would shuffle dramatically. Hawaii would dominate short trips. Southeast Asia would only show up at three weeks. Eastern Europe would probably win the middle.&lt;/p&gt;
&lt;p&gt;That requires real flight pricing data and a defensible US-hub assumption, which I have not built yet. It is the next post in this series if I can find a clean public source.&lt;/p&gt;
&lt;p&gt;For now: the daily number tells you what a day on the ground costs. It does not tell you what the trip costs. Both numbers matter. Most cost-of-travel writing only quotes one of them.&lt;/p&gt;</content:encoded><category>data</category><category>destinations</category><category>travel</category><category>budget</category><category>cost-of-travel</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I&apos;m Only Building Dead Simple Apps From Now On</title><link>https://travelvient.com/blog/only-building-dead-simple-apps/</link><guid isPermaLink="true">https://travelvient.com/blog/only-building-dead-simple-apps/</guid><description>I opened Bloons TD to play a quick round and closed it without playing. Four modals appeared before I could see the homescreen. From now on, my apps do one thing.</description><pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I opened Bloons TD the other day to play a quick round.&lt;/p&gt;
&lt;p&gt;The screen was wall to wall buttons. A daily challenge banner. A season pass popup. A currency reward I had to claim. A new event I had to dismiss. Then another modal selling me an upgrade pack. By the time I was looking at the actual map, I had tapped six things I did not want to tap.&lt;/p&gt;
&lt;p&gt;I closed the app without playing.&lt;/p&gt;
&lt;p&gt;It is a tower defense game. I wanted to place towers and pop bloons. That used to be the whole loop. Now the loop is wrapped in five other loops, and the original one is somewhere down inside, waiting for me to dig it up.&lt;/p&gt;
&lt;p&gt;This is not a Bloons problem. This is every app I open.&lt;/p&gt;
&lt;h2 id=&quot;the-job-to-be-done-is-three-taps-deep&quot;&gt;The job to be done is three taps deep&lt;/h2&gt;
&lt;p&gt;Pick any app on your phone right now. Open it. Count how many things appear before you can do the thing you opened the app to do.&lt;/p&gt;
&lt;p&gt;Banking app: a promo carousel about a new credit card before the balance.
Streaming app: three rows of recommendations before search.
Food delivery: a wall of paid placement before the restaurants you usually order from.
Social app: a story tray, a creator subscription pitch, an explore feed, a “see what your friends are up to” prompt.&lt;/p&gt;
&lt;p&gt;The actual product is buried. The shell around it has eaten the product.&lt;/p&gt;
&lt;p&gt;I do not think the people who built these apps are bad at their jobs. I think the framing has rotted out from under them. Engagement is the metric, not job-completed. So every quarter another team gets staffed, another OKR gets set, another ribbon of UI gets added on top. Nobody is paid to remove anything. The apps grow rings like trees.&lt;/p&gt;
&lt;h2 id=&quot;what-i-am-going-to-do-about-it-on-my-end&quot;&gt;What I am going to do about it on my end&lt;/h2&gt;
&lt;p&gt;I cannot fix Bloons TD. I can fix what I ship.&lt;/p&gt;
&lt;p&gt;From now on, every app I build does exactly one thing. No accounts. No settings screens. No in-app upsells. No notifications. No “while you’re here, why don’t you also.” If a feature does not make the core thing better, it does not ship. If the second feature is not strictly necessary, the app stays a one-feature app.&lt;/p&gt;
&lt;p&gt;I already have one of these in the App Store and I like the way it feels.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://travelvient.com/projects/mic-clipboard&quot;&gt;Mic to Clipboard&lt;/a&gt; is my dead simple iOS app. One screen. One button. You tap it, you talk, and your words land in your clipboard. Paste them anywhere. There are no settings. There is no account. There is no history view. There is no premium tier. The whole app is the thing it does.&lt;/p&gt;
&lt;p&gt;I built it because Claude Code on Bedrock does not expose microphone access and I was tired of typing every prompt by hand. So the app fixes exactly that one friction point. Nothing else. When I open it I am in for two seconds and out, which is exactly what I want from it.&lt;/p&gt;
&lt;p&gt;The next thing I am building is a local events app. Same philosophy. You open it, you see what is happening near you tonight, you close it. No social feed. No RSVPs. No friend system. No “create an account to save events.” If you want to go, screenshot it or write it down. The app’s job is to tell you what is on tonight, not to become a daily habit.&lt;/p&gt;
&lt;h2 id=&quot;why-one-feature-is-a-feature&quot;&gt;Why one feature is a feature&lt;/h2&gt;
&lt;p&gt;There is a real argument here, not just a vibe.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It ships faster.&lt;/strong&gt; A one-feature app is one or two weekends of work. A ten-feature app is a year of work, half of which is plumbing between features, navigation between screens, settings to control feature interaction, and edge cases where feature A breaks feature B.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Users understand it instantly.&lt;/strong&gt; When the app does one thing, the user has nothing to learn. They open it, they see the thing, they do the thing. There is no onboarding because there is nothing to onboard them into.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The bug surface is small.&lt;/strong&gt; Every feature is a place a bug can live. One feature, one place. Less code to maintain, less to break when iOS updates, less for me to forget how it works six months later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Constraints are a feature.&lt;/strong&gt; When I tell myself the app is only allowed to do one thing, I have to make that one thing really good. If I let myself add a settings screen, I would spend a week tuning the settings screen instead of making the core flow shorter. Forbidding the escape valves forces the work into the part that matters.&lt;/p&gt;
&lt;h2 id=&quot;this-is-not-anti-feature&quot;&gt;This is not anti-feature&lt;/h2&gt;
&lt;p&gt;I want to be clear about what I am not saying. I am not saying apps should never grow. I am not saying every app should be a single button with no settings.&lt;/p&gt;
&lt;p&gt;I am saying: add the second feature when the first one stops being enough, not because the roadmap says Q3. Add the settings screen when users are actively asking for a setting, not before. Add the account system when there is a real cross-device need, not because the team’s OKR is “increase signed-in users.”&lt;/p&gt;
&lt;p&gt;Most “features” do not exist because anyone needed them. They exist because somebody had to ship something this quarter and that was the cheapest thing to attach. Then it gets attached. Then it gets defended in the next quarter’s review because killing it would be admitting it was unnecessary. Then the app feels like Bloons TD.&lt;/p&gt;
&lt;h2 id=&quot;the-bar-i-am-holding-myself-to&quot;&gt;The bar I am holding myself to&lt;/h2&gt;
&lt;p&gt;If a feature does not make the core thing better, it does not ship.&lt;/p&gt;
&lt;p&gt;If the app cannot be explained in one sentence, the scope is wrong.&lt;/p&gt;
&lt;p&gt;If the user has to read instructions, the design is wrong.&lt;/p&gt;
&lt;p&gt;If I am tempted to add a settings screen, I should be tightening the defaults instead.&lt;/p&gt;
&lt;p&gt;If I am tempted to add an account, I should be making the offline experience better instead.&lt;/p&gt;
&lt;p&gt;If you have felt the same fatigue lately, opening apps that used to be useful and now feel like a department store, you are not imagining it. The apps actually did get worse. The good news is some of us are going the other way.&lt;/p&gt;
&lt;p&gt;That is what I am building toward. Apps you open, use, and close. Apps that respect that you have something else to do today. Apps where the entire app is the thing it does, and there is nothing else in the way.&lt;/p&gt;
&lt;p&gt;Related: see &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;side projects&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>indie-dev</category><category>product</category><category>design</category><category>essay</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>How to Book Connecting Flights Without Ruining Your Trip</title><link>https://travelvient.com/blog/how-to-book-connecting-flights-without-ruining-your-trip/</link><guid isPermaLink="true">https://travelvient.com/blog/how-to-book-connecting-flights-without-ruining-your-trip/</guid><description>A practical guide to picking layovers that actually work, from minimum connection times to carry-on traps that can wreck your plans at the gate.</description><pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I missed a connection in Dallas once because I booked a 55-minute layover on a ticket that required me to switch terminals, re-clear security, and sprint past 47 gates. The fare was $80 cheaper than the two-hour option. I spent that $80 and then some on a last-minute rebooking, a sad airport sandwich, and a hotel I didn’t plan on needing.&lt;/p&gt;
&lt;p&gt;That was the trip that made me stop guessing at layover times. Now I check before I book. This calculator factors in terminal layouts, customs buffers, and TSA rescreen times for 70 airports:&lt;/p&gt;
&lt;div style=&quot;display:flex;justify-content:center;margin:2rem 0;&quot;&gt;&lt;iframe src=&quot;https://travelvient.com/embed/connection-time/?airport=den&amp;theme=light&amp;color=f2a618&amp;radius=16&quot; style=&quot;width:100%;max-width:720px;border:0;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);&quot; height=&quot;700&quot; loading=&quot;lazy&quot; title=&quot;Connection time calculator&quot;&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;script src=&quot;https://travelvient.com/embed/resize.js&quot; async&gt;&lt;/script&gt;
&lt;p&gt;Pick the airport you’re connecting through and it’ll tell you exactly how much time you need, broken down by scenario. I check this every time before I book a multi-leg itinerary now. Here’s why it matters.&lt;/p&gt;
&lt;h2 id=&quot;the-myth-of-minimum-connection-time&quot;&gt;The myth of minimum connection time&lt;/h2&gt;
&lt;p&gt;Every airport publishes a minimum connection time (MCT), which is the shortest legal gap the airline will sell you on a connecting itinerary. What they don’t tell you is that “minimum” means “theoretically possible if everything goes perfectly.” Your inbound flight lands on time. You’re seated in row 3. The gates happen to be next to each other. Nobody in front of you takes nine minutes to find their bag in the overhead bin.&lt;/p&gt;
&lt;p&gt;In reality, a 20-minute delay on your first leg eats the entire buffer. And delays aren’t rare. They’re the norm.&lt;/p&gt;
&lt;p&gt;Here’s my rule: for domestic connections, book at least 30 minutes over the MCT. For international connections where you need to clear customs and re-check bags, book at least 60 minutes over it. If you’re connecting through a mega-hub like Atlanta, Dallas/Fort Worth, or Denver during peak hours, add another 15 on top of that.&lt;/p&gt;
&lt;p&gt;The tricky part is that MCTs vary wildly by airport, by terminal combination, and by whether you’re connecting domestic-to-domestic versus international-to-domestic. Atlanta’s MCT for a domestic connection is about 30 minutes if you stay in the same concourse. But if you’re arriving international and connecting domestic, you need to clear customs, re-check your bag, go through TSA again, and take the train to a different concourse. That’s a completely different number.&lt;/p&gt;
&lt;h2 id=&quot;the-3-hour-sweet-spot&quot;&gt;The 3-hour sweet spot&lt;/h2&gt;
&lt;p&gt;For most domestic connections, I aim for a 2 to 3 hour window. Short enough that you’re not wasting half your day in an airport. Long enough that a typical 30-minute delay doesn’t matter. You can grab food, stretch your legs, and still get to your gate early.&lt;/p&gt;
&lt;p&gt;For international connections, especially through airports like London Heathrow or Tokyo Narita where you might need to change terminals via a bus or train, I won’t book anything under 3 hours. Four is better. I’ve been burned too many times.&lt;/p&gt;
&lt;p&gt;If you’re traveling with kids, add an hour to whatever number you’d book for yourself. Kids don’t sprint through terminals. They stop to look at things. They need bathrooms at the worst possible time.&lt;/p&gt;
&lt;h2 id=&quot;the-carry-on-problem-nobody-talks-about&quot;&gt;The carry-on problem nobody talks about&lt;/h2&gt;
&lt;p&gt;Here’s the other thing that can wreck a connection: getting your carry-on gate-checked on leg one and having to wait at baggage claim to retrieve it before your next flight.&lt;/p&gt;
&lt;p&gt;This happens more than people realize, and it’s almost always because the bag is technically over the airline’s size limit. Most travelers assume carry-on sizes are universal. They’re not. A bag that fits perfectly in the overhead on a United 737 might be oversized for the regional jet operating your first leg on American Eagle.&lt;/p&gt;
&lt;p&gt;Even on the same airline, carry-on allowances can differ between mainline and regional flights. Some airlines have strict weight limits (looking at you, most European carriers). Some charge for carry-ons entirely if you’re on a basic economy fare.&lt;/p&gt;
&lt;p&gt;Before I fly, I always double-check the actual carry-on dimensions and personal item limits for my specific airline. Here’s a quick way to do that:&lt;/p&gt;
&lt;iframe src=&quot;https://travelvient.com/embed/carry-on/?airline=delta-air-lines&amp;theme=dark&amp;color=f2a618&amp;radius=8&quot; style=&quot;width:100%;max-width:480px;border:0;border-radius:8px;overflow:hidden;margin:1.5rem auto;display:block;&quot; height=&quot;580&quot; loading=&quot;lazy&quot; title=&quot;Carry-on size checker&quot;&gt;&lt;/iframe&gt;
&lt;p&gt;The weight limits are the sneaky ones. Ryanair’s 10 kg carry-on limit has caught more first-time European travelers off guard than any other rule in aviation. And if your bag is over at the gate, you’re paying the oversized fee on the spot, which is always more expensive than prepaying online.&lt;/p&gt;
&lt;h2 id=&quot;my-pre-booking-checklist&quot;&gt;My pre-booking checklist&lt;/h2&gt;
&lt;p&gt;After enough bad layovers, I now run through these five things before I click “purchase” on any connecting itinerary:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Look up the MCT for the connecting airport.&lt;/strong&gt; Not the airline’s generic recommendation. The actual terminal-specific minimum.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add my buffer.&lt;/strong&gt; 30 minutes domestic, 60 minutes international, more for mega-hubs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the aircraft type for each leg.&lt;/strong&gt; If leg one is a regional jet, your overhead space is smaller. Plan for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify carry-on limits for each airline.&lt;/strong&gt; If the itinerary is on two different carriers (common with codeshares), check both. Your bag needs to comply with whichever airline is operating the flight.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the terminal map.&lt;/strong&gt; Some airports (looking at you, JFK) have terminals that require you to exit security and re-enter. That eats 30+ minutes even if the airline says the MCT is shorter.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;the-overnight-layover-trick&quot;&gt;The overnight layover trick&lt;/h2&gt;
&lt;p&gt;If you’re flexible and the fare difference is significant, an overnight layover can actually be a feature, not a problem. I’ve had some of my best travel memories from unplanned overnight stops.&lt;/p&gt;
&lt;p&gt;A 14-hour layover in Reykjavik let me see the Northern Lights. An overnight in Lisbon gave me an evening of incredible food I wouldn’t have experienced otherwise. Some airports even offer free or subsidized city tours for transit passengers (Singapore Changi, Istanbul, and Doha all have programs like this).&lt;/p&gt;
&lt;p&gt;The key is knowing it’s coming and planning for it. Don’t check your bag through to the final destination if you want to leave the airport. Pack a change of clothes and your essentials in your carry-on. And make sure you don’t need a transit visa for the country you’re stopping in.&lt;/p&gt;
&lt;h2 id=&quot;the-real-cost-of-a-cheap-layover&quot;&gt;The real cost of a cheap layover&lt;/h2&gt;
&lt;p&gt;That $80 I saved on the Dallas connection ended up costing me around $340 when I added the rebooking fee, the hotel, the meals, and the Uber. And I lost an entire evening I was supposed to spend with family.&lt;/p&gt;
&lt;p&gt;The math almost never works in favor of the tight connection. Book the extra time. Check your carry-on dimensions before you leave for the airport, not at the gate. And treat the layover as part of the trip, not an obstacle between you and the destination.&lt;/p&gt;
&lt;p&gt;Your future self, the one standing calmly at the gate with 45 minutes to spare, will thank you.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/guides/minimum-connection-time-jfk-2026/&quot;&gt;minimum connection times at JFK&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/&quot;&gt;carry-on size checker&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>travel-tips</category><category>flights</category><category>layovers</category><category>packing</category><category>airports</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>15 Countries Where Your US Plug Fits but the Voltage Doesn&apos;t</title><link>https://travelvient.com/blog/15-countries-us-plug-fits-voltage-doesnt/</link><guid isPermaLink="true">https://travelvient.com/blog/15-countries-us-plug-fits-voltage-doesnt/</guid><description>Your US-shaped plug fits the wall in 15 countries that run on 230V. Your hair dryer expects 110V. The plug fits. The voltage cooks the device.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Your US-shaped plug fits the wall in 15 countries that deliver double the voltage your device was built for. Phone chargers and laptops switched to dual voltage years ago. Hair dryers, curling irons, electric razors, and small kitchen appliances mostly did not.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Thailand, Vietnam, the Philippines, Cambodia, Laos, Myanmar, China, Peru, Bolivia, Paraguay, Bangladesh, Antigua and Barbuda, Guyana, Saint Kitts and Nevis, and Saint Vincent and the Grenadines.&lt;/strong&gt; All 15 accept Type A or Type B sockets (the flat US shape) but run a 230V to 240V grid. The data comes from a 221-country plug-and-voltage table cross-checked against IEC standards.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The plug fit is not the question. The voltage is.&lt;/strong&gt; A 110V US hair dryer plugged into a 230V Bangkok outlet draws four times its rated wattage. The heating element fails, the fuse blows, or the housing melts. Some pop on the first second.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Phone and laptop chargers are mostly safe.&lt;/strong&gt; Look at the small print on the brick. If it reads 100-240V, you are fine anywhere on the list. If it reads 120V only, treat it like a hair tool and leave it home.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Single-voltage hair tools are the most common casualty.&lt;/strong&gt; A US-bought curling iron rated 120V draws 1500W in Hanoi, Manila, or Phnom Penh and will burn out before it warms up. Travel-specific dual-voltage hair tools exist. The drugstore version on your bathroom counter does not.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;A travel adapter does not change voltage.&lt;/strong&gt; It only changes plug shape. Running a 110V device on a 230V grid takes a step-down transformer, which is heavy and rarely worth it. For most travelers the answer is leaving the device at home.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Check the input range printed on the charger before you pack it.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/tools/plug-finder/&quot;&gt;travel power adapter finder&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/destinations/&quot;&gt;destination guides&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>travel</category><category>electronics</category><category>packing</category><category>data</category><category>international-travel</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Checked 21 Airline Pet Fees. The Spread Is $74 to $300.</title><link>https://travelvient.com/blog/21-airline-pet-fees-74-to-300-spread/</link><guid isPermaLink="true">https://travelvient.com/blog/21-airline-pet-fees-74-to-300-spread/</guid><description>Round-trip in-cabin pet fees range from $74 on WestJet to $300 on United and American. Same pet, same cabin, 4x price difference depending on the airline.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Editor’s note (May 2026):&lt;/strong&gt; This data essay was published before Spirit Airlines ceased operations on May 2, 2026 (Chapter 7 liquidation). Spirit’s pet fee below reflects its policy while it operated and is kept as a historical snapshot; Spirit is no longer bookable.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The same 15-pound dog in the same under-seat carrier costs $74 round-trip on one airline and $300 on another.&lt;/p&gt;
&lt;p&gt;Most pet owners book flights by schedule and price, then discover the pet fee at checkout. The fee varies 4x depending on the carrier, and nothing about the service changes between the cheapest and most expensive.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;WestJet charges $37 one-way. That is $74 round-trip for the exact same under-seat pet carrier every airline requires. Allegiant is $50 one-way ($100 round-trip). Both are lower than Delta’s $95 one-way, which most travelers assume is standard.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The middle tier clusters around $100-$125. Delta charges $95, Alaska $100, Southwest $125, JetBlue $125. Spirit also charges $125, which means Spirit’s pet fee matches Southwest’s despite Spirit being the “budget” option.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;American and United both charge $150 one-way. That is $300 round-trip. For two pets on a family trip, that is $600 in pet fees alone on a single domestic round-trip.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Nine airlines charge under $100 one-way. WestJet, Allegiant, Eurowings, Gol, Breeze, Sun Country, Air Canada, Delta, and Frontier. The cheapest five are all under $75.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The airline with the lowest base fare is not the cheapest airline to fly with a pet.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/&quot;&gt;carry-on size checker&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/guides/best-airline-for-flying-with-pets-in-cabin/&quot;&gt;best airline for flying with pets in cabin&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>airlines</category><category>pets</category><category>travel</category><category>baggage-fees</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>Building a Travel Power Adapter Tool with Claude in a Weekend</title><link>https://travelvient.com/blog/building-plug-finder/</link><guid isPermaLink="true">https://travelvient.com/blog/building-plug-finder/</guid><description>How I turned leftover destination data into a 221-country power adapter finder with plug types and voltage comparison. The first version was unusable.</description><pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I travel a lot. And every single time, I’m standing in an airport Googling “do I need an adapter for Thailand” while my boarding group is already lining up. The existing tools for this are bad. They’re buried in blog posts from 2016 with popup ads, or they’re a wall of text that doesn’t actually answer the question.&lt;/p&gt;
&lt;p&gt;I already had most of the data. My destination guides on Travel Vient cover dozens of countries, and each one tracks the local plug type and voltage. The pieces were sitting there. I just needed to turn them into something interactive.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;You pick where you’re from and where you’re going. It tells you: do you need an adapter, do you need a voltage converter, or do you need nothing. It covers 221 countries, all 15 plug types (A through O), and links you to the right adapter on Amazon if you need one.&lt;/p&gt;
&lt;p&gt;There’s a collapsible section with plug diagrams, a voltage comparison, and a converter guide. A table of popular routes (US to UK, US to France, etc.) with pre-computed answers. FAQs for the common questions. And the whole thing also works as an embeddable widget other travel sites can iframe in.&lt;/p&gt;
&lt;h2 id=&quot;how-i-worked-with-claude-on-this-one&quot;&gt;How I worked with Claude on this one&lt;/h2&gt;
&lt;p&gt;This was almost entirely Claude Code agentic runs. I described what I wanted, pointed it at my existing destination data, and let it build. My role was more like a product manager reviewing output than a developer writing code. I’d describe the feature, Claude would ship a complete implementation, I’d review it, and then tell it what to fix.&lt;/p&gt;
&lt;p&gt;The initial build took one agentic run. The restyle took another. The whole thing was done in two sessions over two days.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;Astro static page with all logic running client-side in a single &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block. No framework, no React, no build step beyond what Astro already does. The country data and plug type info get serialized into JSON script tags at build time, then the client-side JS reads them and renders results.&lt;/p&gt;
&lt;p&gt;I considered making this a separate app like PackSmart, but it didn’t need a backend. Every possible result is just a comparison between two objects in a JSON array. No API calls, no server, no loading states.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; getAdapterVerdict&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;origin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;CountryElectrical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;destination&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;CountryElectrical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;AdapterVerdict&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; originPlugs&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Set&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;origin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;plugTypes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; shared&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; destination&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;plugTypes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;filter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; originPlugs&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;has&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; missing&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; destination&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;plugTypes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;filter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;originPlugs&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;has&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; needsAdapter&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; shared&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; voltageMatch&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;abs&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;origin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;voltage&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; destination&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;voltage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 20&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; needsConverter&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;voltageMatch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; freqOrigin&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Array&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;isArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;origin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frequency&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; origin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frequency&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;origin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frequency&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; freqDest&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Array&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;isArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;destination&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frequency&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; destination&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frequency&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;destination&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frequency&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; frequencyMatch&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; freqOrigin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;some&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; freqDest&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;includes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;needsAdapter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;needsConverter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;sharedPlugTypes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;shared&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;missingPlugTypes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;missing&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;voltageMatch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frequencyMatch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s the core logic. If none of your home country’s plug types exist in the destination’s list, you need an adapter. If the voltage difference is more than 20V, you need a converter. Simple, but it handles the edge cases: countries with multiple plug types, dual-voltage regions, frequency mismatches.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part&quot;&gt;The hard part&lt;/h2&gt;
&lt;p&gt;Information hierarchy. The tool needs to answer one question fast: “do I need to buy something before my trip?” But it also needs to handle the follow-up questions: “what type?” and “what about my hair dryer?”&lt;/p&gt;
&lt;p&gt;Claude’s first implementation threw everything at the user at once. Plug diagrams, voltage charts, converter guides, notes about regional voltage variations. It was technically complete and practically unusable. Nobody landing on this page from Google wants to parse a wall of technical data. They want a yes/no verdict with a link to buy the thing.&lt;/p&gt;
&lt;p&gt;The fix was progressive disclosure. The verdict sits at the top in a bold banner. The Amazon link is right below it. Everything else collapses behind a details element:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;html&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;details&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; id&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;pf-details&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;pf-details mt-3&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;summary&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;pf-details-summary&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;span&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;Voltage details, plug picture, and converter guide&amp;lt;/&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;span&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  &amp;lt;/&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;summary&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;div&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;pf-details-body&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;    &amp;lt;!-- Plug type cards, voltage comparison, converter advice --&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  &amp;lt;/&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;div&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;/&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;details&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Simple pattern, but it took two full restyle passes to get there because Claude kept defaulting to “show everything.”&lt;/p&gt;
&lt;h2 id=&quot;where-claude-surprised-me&quot;&gt;Where Claude surprised me&lt;/h2&gt;
&lt;p&gt;The data layer. Claude compiled a 221-country JSON dataset with plug types, voltages, frequencies, and edge-case notes in a single pass. Countries like Brazil where voltage varies by region, Cambodia where five different plug types are in use, Bangladesh where Type D and G are most common despite officially supporting five types.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Brazil&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;iso&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;BR&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;plugTypes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;C&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;N&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;], &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;voltage&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;127&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;frequency&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;60&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;notes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Voltage varies by region: 127V in most of the southeast (Sao Paulo, Rio) and 220V in the south (Brasilia, Florianopolis) and northeast (Recife, Salvador). Always check before plugging in.&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I spot-checked a bunch of these against IEC standards and travel forums. They were accurate. Claude also generated all 15 plug type SVGs inline, each with the correct pin layout, grounding, and shape. No external image files needed. That saved me from sourcing or licensing illustrations.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-fell-short&quot;&gt;Where Claude fell short&lt;/h2&gt;
&lt;p&gt;The initial UI was a disaster. Claude built something that was technically functional but impossible to actually use. The country selectors were basic dropdowns with no search. The results showed every data point at once with no hierarchy. There was no visual distinction between “you need nothing” and “you need a specific adapter.”&lt;/p&gt;
&lt;p&gt;I had to run a complete restyle. The first attempt at the restyle was still too busy. I ended up having to be very specific about what I wanted: a horizontal picker row with flag emojis, a single bold verdict line, a dark CTA button, and everything else hidden behind a collapsible. Claude needed that level of direction to produce something clean.&lt;/p&gt;
&lt;p&gt;The country picker alone went through multiple iterations:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; initPicker&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;HTMLElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;onSelect&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;iso&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; trigger&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelector&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;.pf-picker-trigger&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;as&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; HTMLButtonElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; panel&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelector&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;.pf-picker-panel&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;as&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; HTMLElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; search&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelector&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;.pf-picker-search&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;as&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; HTMLInputElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // ...keyboard nav, filtering, popular chips, scroll-into-view&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;setTriggerDisplay&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;selectIso&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This picker has search, keyboard navigation, popular country chips, and proper ARIA. None of that existed in the first version. Claude could build each individual piece when asked, but it couldn’t intuit what the right UX shape was from a high-level description.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong-overall&quot;&gt;What went wrong overall&lt;/h2&gt;
&lt;p&gt;Scope was actually fine here. The real problem was making two things at once: a standalone tool page and an embeddable widget. The widget version needs to work in an iframe at arbitrary widths, with theme detection, without any of the page chrome. That doubled the surface area for styling bugs.&lt;/p&gt;
&lt;p&gt;I also launched it hidden behind a sitemap exclusion initially, which means I didn’t get real feedback on the UX until I enabled it on the tools page. If I’d shown it to someone earlier, I might have caught the information-overload problem faster.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;Live at &lt;a href=&quot;https://travelvient.com/tools/plug-finder/&quot;&gt;/tools/plug-finder&lt;/a&gt;. Indexed, included in the tools listing, has an embeddable widget version for outreach. The affiliate links are working. It handles URL parameters (&lt;code&gt;?from=US&amp;amp;to=GB&lt;/code&gt;) so you can deep-link to specific routes.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;Start with a wireframe. Not a pixel-perfect mockup, just a rough sketch of the information hierarchy: what does the user see first, what’s hidden, what’s the action. Claude is great at building whatever you describe, but it defaults to “show all the data” when you don’t constrain the layout upfront. A 30-second sketch on paper would have saved me the entire restyle pass.&lt;/p&gt;
&lt;p&gt;I’d also be more explicit about the “nothing needed” state. That’s actually the most satisfying result for the user, and it deserves its own distinct treatment. Claude’s first version treated it as just another line of text. Now it gets a big “Nothing.” headline. Small thing, but it’s the difference between a tool that feels good to use and one that just dumps information.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/tools/plug-finder/&quot;&gt;plug finder tool&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/15-countries-us-plug-fits-voltage-doesnt/&quot;&gt;the voltage data behind it&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>astro</category><category>devlog</category><category>built-with-claude</category><category>travel-tools</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I tested the &apos;avoid August&apos; rule against 67 destinations</title><link>https://travelvient.com/blog/67-destinations-august-crowd-test-2026/</link><guid isPermaLink="true">https://travelvient.com/blog/67-destinations-august-crowd-test-2026/</guid><description>22 of 23 European cities hit peak crowd in August. But 16 destinations globally sit at low crowd. The dataset, the chart, and the cities that break the rule.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We went to Hawaii in September. Not early September, not “end of summer” September. Properly into the month. Locals at a trailhead on Oahu mentioned how packed it had been the week before, how the parking lots had overflowed all through August, how we had just missed the chaos. We hiked Diamond Head with maybe fifteen other people. The North Shore beaches were empty enough to sit wherever we wanted. One week earlier, apparently, you could not find a parking spot.&lt;/p&gt;
&lt;p&gt;That timing stuck with me. The difference between August and September at the same destination was the difference between fighting for space and having it to yourself. And it got me wondering: is August actually the universal worst-case for every destination, or is that mostly a European and North American story?&lt;/p&gt;
&lt;p&gt;So I tested it. 67 destinations. One question per city: what is the crowd level in August?&lt;/p&gt;
&lt;p&gt;22 of 23 European cities hit peak crowd. But 16 destinations globally, nearly a quarter of the dataset, sit at low crowd in August.&lt;/p&gt;
&lt;h2 id=&quot;how-i-tested-this&quot;&gt;How I tested this&lt;/h2&gt;
&lt;p&gt;I maintain structured JSON files for 67 destinations at &lt;a href=&quot;https://travelvient.com/&quot;&gt;travelvient.com&lt;/a&gt;. Each file includes a &lt;code&gt;seasons&lt;/code&gt; array with &lt;code&gt;crowdLevel&lt;/code&gt; classifications (peak, high, moderate, low) broken out by month range, plus a &lt;code&gt;bestTimeToVisit.avoidPeriod&lt;/code&gt; field where the data explicitly warns against certain months.&lt;/p&gt;
&lt;p&gt;For each destination, I found the season entry whose month range includes August and read its &lt;code&gt;crowdLevel&lt;/code&gt; value. I also checked whether August appeared in the &lt;code&gt;avoidPeriod&lt;/code&gt; string.&lt;/p&gt;
&lt;p&gt;The data was last updated between April 23 and April 29, 2026. Four destinations (Bali, Cartagena, Dubai, Lima) lacked August crowd data and are excluded from the crowd-level counts but included in the chart with “n/a” markers.&lt;/p&gt;
&lt;p&gt;The full dataset is downloadable as a CSV: &lt;a href=&quot;https://travelvient.com/data/67-destinations-august-crowd-test-2026.csv&quot;&gt;67-destinations-august-crowd-test-2026.csv&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Two things this test does NOT do. It does not measure crowd levels with sensors or ticket-gate counts. These are editorial classifications based on tourism board data, occupancy patterns, and published travel guides. Different sources would draw the lines differently. And it classifies at the city level, not the country level. A city being “peak” does not mean the entire country is packed. Rural Provence in August is a different experience from Paris.&lt;/p&gt;
&lt;h2 id=&quot;the-chart&quot;&gt;The chart&lt;/h2&gt;
&lt;p&gt;Bar width shows the August high temperature in Fahrenheit (scaled to 109°F, Cairo’s high). Color shows crowd level: &lt;span style=&quot;color: #ef4444;&quot;&gt;red = peak/high&lt;/span&gt;, &lt;span style=&quot;color: #f59e0b;&quot;&gt;amber = moderate&lt;/span&gt;, &lt;span style=&quot;color: #10b981;&quot;&gt;green = low&lt;/span&gt;.&lt;/p&gt;
&lt;div class=&quot;data-chart-legend&quot;&gt;&lt;span class=&quot;fail&quot;&gt;Peak / High&lt;/span&gt;&lt;span class=&quot;borderline&quot;&gt;Moderate&lt;/span&gt;&lt;span class=&quot;pass&quot;&gt;Low&lt;/span&gt;&lt;/div&gt;
&lt;h4 id=&quot;europe-23&quot;&gt;Europe (23)&lt;/h4&gt;
&lt;div class=&quot;data-chart&quot;&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 66%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Amsterdam&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;72°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Athens&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 77%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Barcelona&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;84°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 71%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Berlin&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;77°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 83%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Budapest&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;90°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 66%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Copenhagen&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;72°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 62%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Dublin&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;68°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Dubrovnik&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 62%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Edinburgh&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;68°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 87%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Florence&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;95°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 77%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Istanbul&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;84°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 79%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Krakow&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;86°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 77%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Lisbon&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;84°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 70%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;London&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;76°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 72%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Paris&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;79°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 75%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Porto&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;82°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 70%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Prague&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;76°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 54%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Reykjavik&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;59°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 82%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Rome&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;89°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 83%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Santorini&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;90°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 80%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Tbilisi&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;87°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 79%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Vienna&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;86°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 89%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Madrid&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;97°F&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Every single bar is red. Madrid is the closest thing to an exception, classified as “high” rather than “peak” because the 97°F heat drives tourists toward coastal Spain. But high is not moderate. Europe in August is wall-to-wall.&lt;/p&gt;
&lt;h4 id=&quot;north-america-24&quot;&gt;North America (24)&lt;/h4&gt;
&lt;div class=&quot;data-chart&quot;&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 75%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Boston&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;82°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 84%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Chicago&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;92°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Denver&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 78%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Los Angeles&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;85°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 87%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Nashville&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;95°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 82%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;New York City&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;89°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 84%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Orlando&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;92°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 75%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Portland&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;82°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 71%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;San Diego&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;77°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 61%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;San Francisco&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;67°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 72%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Seattle&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;79°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 80%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Honolulu&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;87°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 97%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Las Vegas&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;106°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 81%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Maui&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;88°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 83%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Washington, DC&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;90°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar: 90%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Austin&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;98°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar: 87%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cabo San Lucas&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;95°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar: 83%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Charleston&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;91°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cancun&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 83%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Costa Rica&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;90°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 71%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Mexico City&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;77°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 84%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Miami&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;92°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 87%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;New Orleans&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;95°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 78%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Oaxaca&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;85°F&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;North America is more interesting. The US cities you associate with summer, from New York to Seattle to Hawaii, are all peak or high. But scroll down to the green bars. Cancun is low-crowd in August (hurricane season scares people off). Mexico City at 77°F and low crowd is one of the best-value entries in the entire dataset. Miami and New Orleans are low-crowd because the heat and humidity are brutal, and the locals know it.&lt;/p&gt;
&lt;h4 id=&quot;asia-11&quot;&gt;Asia (11)&lt;/h4&gt;
&lt;div class=&quot;data-chart&quot;&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Osaka&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Hong Kong&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar: 87%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Kyoto&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;95°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar: 83%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Singapore&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;91°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar: 82%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Tokyo&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;89°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Bangkok&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 83%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Chiang Mai&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;90°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 85%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Ho Chi Minh City&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;93°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 79%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Seoul&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;86°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 87%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Taipei&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;95°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row &quot; style=&quot;--bar: 0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Bali&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;n/a&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Zero peak-crowd cities. Only Osaka hits peak. The Japanese cities (Kyoto, Tokyo) are moderate because the Obon holiday in mid-August brings domestic travel, but international tourist volume is lower than spring cherry blossom season. Southeast Asia is almost entirely low-crowd in August: it is monsoon season across Thailand, Vietnam, and Taiwan, which drops prices and thins crowds even though the rain is usually a short afternoon burst rather than an all-day washout.&lt;/p&gt;
&lt;p&gt;Seoul at 86°F and low crowd is a standout. The city has excellent transit, the food scene does not slow down in summer, and August sits between the spring and autumn tourist waves.&lt;/p&gt;
&lt;h4 id=&quot;africa-3&quot;&gt;Africa (3)&lt;/h4&gt;
&lt;div class=&quot;data-chart&quot;&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 100%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cairo&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;109°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 58%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cape Town&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;63°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 99%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Marrakech&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;108°F&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;All three African cities are low-crowd. All three green. But look at the bar widths. Cairo hits 109°F and Marrakech 108°F. These are not hidden gems. They are empty because the heat is dangerous. Cape Town is a different story: it is winter in the Southern Hemisphere, 63°F, and genuinely pleasant for hiking and wine country if you pack layers.&lt;/p&gt;
&lt;h4 id=&quot;south-america-4&quot;&gt;South America (4)&lt;/h4&gt;
&lt;div class=&quot;data-chart&quot;&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar: 72%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Medellin&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;78°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 59%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Buenos Aires&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;64°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row &quot; style=&quot;--bar: 0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cartagena&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;n/a&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row &quot; style=&quot;--bar: 0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Lima&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;n/a&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Buenos Aires in August is winter. Highs around 64°F, low crowds, and steak dinner prices that look like a rounding error compared to Paris. The city does not shut down in winter; the theater and restaurant scene stays active.&lt;/p&gt;
&lt;h4 id=&quot;oceania-1--middle-east-1&quot;&gt;Oceania (1) &amp;amp; Middle East (1)&lt;/h4&gt;
&lt;div class=&quot;data-chart&quot;&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar: 58%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Sydney&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;63°F&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row &quot; style=&quot;--bar: 0%;&quot;&gt;&lt;span class=&quot;name&quot;&gt;Dubai&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;n/a&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Sydney in August is mid-winter, 63°F, low crowd. The Opera House, the harbor walks, and the Blue Mountains are all open. You will want a coat.&lt;/p&gt;
&lt;h2 id=&quot;the-findings-ranked&quot;&gt;The findings, ranked&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The rule is right in Europe.&lt;/strong&gt; 22 of 23 cities hit peak. 14 of those 23 explicitly list August in their avoid period. This is not a nuanced finding. If you are going to Europe in August, you are going at the most crowded, most expensive time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The rule breaks down outside Europe and the US.&lt;/strong&gt; Asia has zero peak-crowd cities in August. Africa has zero. South America has zero (Medellin is “high” but not peak). The “avoid August” advice is really “avoid August in the Northern Hemisphere’s wealthy tourist corridors.”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Southern Hemisphere flips the script.&lt;/strong&gt; Cape Town, Sydney, and Buenos Aires are all in winter during August. Low crowds, lower prices, and weather that ranges from brisk to mild. This is the most reliable August contrarian play in the dataset.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Some low-crowd cities are low for a reason.&lt;/strong&gt; Cairo at 109°F and Marrakech at 108°F are not invitations. They are warnings. Miami at 92°F with peak humidity is technically low-crowd, but if you have spent an August afternoon in South Florida, you understand the tradeoff. “Low crowd” and “good time to visit” are not the same thing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The genuine surprises are Seoul, Mexico City, and Bangkok.&lt;/strong&gt; Seoul (86°F, low crowd) has comfortable weather and does not thin out its food and nightlife scene in summer. Mexico City (77°F, low crowd) is 7,350 feet above sea level, which keeps August temperatures mild, and the rainy season means brief afternoon showers rather than gray all-day rain. Bangkok (93°F, low crowd) is hot, yes, but it is always hot, and the monsoon-season hotel rates can run 40-50% below peak.&lt;/p&gt;
&lt;p&gt;If you want to check seasonal details for any of these cities, I keep updated destination guides at &lt;a href=&quot;https://travelvient.com/destinations/&quot;&gt;travelvient.com&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-this-data-does-not-tell-you&quot;&gt;What this data does not tell you&lt;/h2&gt;
&lt;p&gt;These crowd levels are editorial classifications. I built them from tourism board data, published travel guides, and hotel occupancy patterns. They are not measured with turnstile counts or cell-phone density data. A different source might draw the peak/moderate/low boundary in a different place, and I would not argue with them.&lt;/p&gt;
&lt;p&gt;The city-level view also hides a lot of variance. Paris being “peak” does not mean every arrondissement is equally packed. The Marais and the Eiffel Tower area are shoulder-to-shoulder in August, but the 13th arrondissement is not. Edinburgh during the Fringe Festival in August is a different city than Edinburgh the week before the Fringe starts.&lt;/p&gt;
&lt;p&gt;And “low crowd” sometimes just means “bad weather.” I flagged this in the Africa section, but it applies elsewhere too. Cancun is low-crowd in August because it is hurricane season. The statistical likelihood of a direct hit during your specific week is small, but the risk changes your refund calculus, and some hotels close their beach services during storm watches.&lt;/p&gt;
&lt;h2 id=&quot;the-real-problem-is-pricing&quot;&gt;The real problem is pricing&lt;/h2&gt;
&lt;p&gt;Here is the take I would defend at a bar. The crowds are manageable. You can book timed-entry tickets for the Uffizi. You can reserve a restaurant in Barcelona a week ahead. You can wake up early and beat the lines at Dubrovnik’s walls. Crowds are an inconvenience, not a dealbreaker.&lt;/p&gt;
&lt;p&gt;The pricing is the dealbreaker. A round-trip flight to Rome in August can run $1,200 from the East Coast. The same flight in October: $550. A hotel in Santorini that costs $180/night in May costs $400+ in August. For a family of four on a ten-day trip, the August premium can add up to $3,000-$5,000 in flights and hotels alone. That is not an inconvenience. That is a second vacation’s worth of money.&lt;/p&gt;
&lt;p&gt;The 16 low-crowd destinations in this dataset are interesting not because they are secret or obscure, but because they are priced for off-season while the rest of the world’s travelers pile into Europe and pay the premium.&lt;/p&gt;
&lt;h2 id=&quot;what-i-want-to-test-next&quot;&gt;What I want to test next&lt;/h2&gt;
&lt;p&gt;I have seasonal data for all 67 destinations, not just August. I want to run the same test for every month and find the month where the most destinations globally hit low crowd. My guess is February or November, but the data might surprise me. I also want to cross-reference crowd level against the daily budget cost data I maintain for these same cities, which would answer a more practical question: where is the cheapest low-crowd destination for each month of the year?&lt;/p&gt;
&lt;p&gt;The dataset CSV is here: &lt;a href=&quot;https://travelvient.com/data/67-destinations-august-crowd-test-2026.csv&quot;&gt;67-destinations-august-crowd-test-2026.csv&lt;/a&gt;. If anything looks wrong or you have crowd data from a city I missed, I would like to hear about it.&lt;/p&gt;</content:encoded><category>data</category><category>destinations</category><category>travel</category><category>august</category><category>crowd-levels</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/europe-august.C_Wn4erM.webp" length="0" type="image/jpeg"/></item><item><title>I Checked Carry-On Rules at 75 Airlines: The Real Trap</title><link>https://travelvient.com/blog/75-airline-personal-item-trap-2026/</link><guid isPermaLink="true">https://travelvient.com/blog/75-airline-personal-item-trap-2026/</guid><description>Across 75 airlines, 21 don&apos;t publish personal item dimensions and the 54 that do range threefold in size. The bag you tuck under the seat is where the rules go.</description><pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Editor’s note (May 2026):&lt;/strong&gt; Published before Spirit Airlines shut down on May 2, 2026 (Chapter 7 liquidation). Spirit’s numbers in the dataset below are a historical snapshot from when it still flew; the airline no longer operates.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I watched someone put their backpack in a sizer at a gate a couple of months ago. It was less than an inch over the personal item rule. The agent apologized, said no, and made her check the bag. She walked away frustrated. I walked away thinking about the data.&lt;/p&gt;
&lt;p&gt;That moment kicked off a small project. I pulled the published carry-on and personal item rules for 75 airlines and tested both. The carry-on data came out about how I expected: 20 of 75 airlines reject a standard 22 by 14 by 9 inch suitcase outright on at least one published dimension. The personal item data did not. 21 of 75 airlines do not publish personal item dimensions at all, including Delta, the largest US carrier. Among the 54 that do, the smallest published allowance is roughly one third the volume of the largest.&lt;/p&gt;
&lt;h2 id=&quot;how-i-tested&quot;&gt;How I tested&lt;/h2&gt;
&lt;p&gt;The data lives in a JSON file on my server. 75 airlines, each with a &lt;code&gt;carryOn&lt;/code&gt; object and a &lt;code&gt;personalItem&lt;/code&gt; object. Both have a published &lt;code&gt;dimensionsIn&lt;/code&gt; field for length, width, and height when the airline publishes them, plus a flag for whether basic economy includes the carry-on. The data is verified against airline policy pages on a rolling 30-day cadence; the snapshot behind this post was last re-verified on May 21, 2026. The full per-airline dataset including everything below is at &lt;a href=&quot;https://travelvient.com/data/75-airline-personal-item-trap-2026.csv&quot;&gt;/data/75-airline-personal-item-trap-2026.csv&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The test bag is 22 by 14 by 9 inches. That is the size most US travelers picture when they hear “carry-on,” and the size most US-made hardshell suitcases ship as. I categorized each airline three ways. &lt;strong&gt;Pass&lt;/strong&gt; means all three published dimensions are at or above 22, 14, and 9. &lt;strong&gt;Borderline&lt;/strong&gt; means a single dimension is within half an inch of the test bag, and the others pass. &lt;strong&gt;Fail&lt;/strong&gt; means at least one dimension is more than half an inch short of the test bag.&lt;/p&gt;
&lt;p&gt;I used a dimension-by-dimension test, not the linear-inches sum. Linear inches (length plus width plus height, capped at 45 in most rules) hides the cases where a deep bag fits a tall slot. The dimension test is the version that tracks how a gate sizer actually checks: the bag has to drop into the slot, in any orientation, on all three sides.&lt;/p&gt;
&lt;h2 id=&quot;the-carry-on-results&quot;&gt;The carry-on results&lt;/h2&gt;
&lt;div class=&quot;data-chart-legend&quot;&gt;&lt;span class=&quot;pass&quot;&gt;passes 22 by 14 by 9&lt;/span&gt;&lt;span class=&quot;borderline&quot;&gt;within 0.5 in below on one dimension&lt;/span&gt;&lt;span class=&quot;fail&quot;&gt;more than 0.5 in below on at least one dimension&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;data-chart&quot;&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:100%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Sun Country Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;24 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:100%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Southwest Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;24 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:100%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Frontier Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;24 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Spirit Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;British Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Norse Atlantic Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Saudia&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;easyJet&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Thai Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Allegiant Air&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Volaris&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Viva Aerobus&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Transavia&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Porter Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Discover Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;SunExpress&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Air India&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Air Arabia&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Iberia&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Virgin Atlantic&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Etihad Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cathay Pacific&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;AirAsia&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Qantas&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Virgin Australia&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Copa Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Delta Air Lines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;American Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;United Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Alaska Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;JetBlue&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Hawaiian Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Breeze Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Jetstar Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Bamboo Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;VietJet Air&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;China Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;EVA Air&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row pass&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Cebu Pacific&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:92%&quot;&gt;&lt;span class=&quot;name&quot;&gt;WestJet&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;22 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Aeromexico&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;TAP Air Portugal&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;ANA All Nippon Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Japan Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Aer Lingus&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Lufthansa&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Wizz Air&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;SWISS International Air Lines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Austrian Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;SAS Scandinavian Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Finnair&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Turkish Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Air New Zealand&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;ITA Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Vueling&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Singapore Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Korean Air&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Emirates&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;flydubai&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Air France&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;KLM Royal Dutch Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;IndiGo&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Azul Linhas Aereas&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;GOL Linhas Aéreas&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Norwegian Air Shuttle&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.6 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Condor&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.6 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Eurowings&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.6 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Ryanair&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.6 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Pegasus Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.6 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Avianca&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.6 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;LATAM Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.6 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row borderline&quot; style=&quot;--bar:90%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Air Canada&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.5 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:89%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Scoot&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;21.3 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:82%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Qatar Airways&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;19.7 in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;row fail&quot; style=&quot;--bar:50%&quot;&gt;&lt;span class=&quot;name&quot;&gt;Spring Airlines&lt;/span&gt;&lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;&lt;span class=&quot;value&quot;&gt;12 in&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;37 airlines pass the test. 18 are borderline. 20 fail outright. The shape is rougher than I expected: more outright failures than the first cut showed, after the May refresh against airline policy pages.&lt;/p&gt;
&lt;p&gt;The pass list groups along familiar lines. Three US ultra-low-cost carriers top the chart with 24-inch length allowances: Sun Country, Southwest, and Frontier. The big middle band sits at exactly 22 inches: US legacy carriers, a mix of European flag carriers, the major Asian and Middle Eastern carriers that map their published rule directly to the US convention. Volaris and Saudia both sit at 22, not 22.4, after the May verification pass.&lt;/p&gt;
&lt;p&gt;The borderline cluster is more interesting. Almost all of it sits at 21.7 inches. That number is not arbitrary. 21.7 inches is exactly 55 cm. Most non-US carriers set their published rule in centimeters, then convert to inches with a touch of rounding, and 55 cm is the number they use. The standard US carry-on is 22 inches. The standard international carry-on is 55 cm. Those are not the same number. They are 0.3 inches apart. By the published rule, a US-bought 22-inch suitcase is technically out of spec at Lufthansa, SWISS, Air New Zealand, ANA, Japan Airlines, Aer Lingus, ITA, Turkish, and several Nordic carriers. The gap is small enough that gate sizers usually do not catch it. The gap is also why every traveler who has flown both has felt some version of this confusion.&lt;/p&gt;
&lt;p&gt;The outright fails sort along their own logic. Ryanair, Pegasus, and Vueling publish 55 by 40 by 20 cm, which is 21.6 by 15.7 by 7.9 inches. The 7.9-inch depth is what kills a 9-inch standard suitcase. Singapore Airlines and Korean Air both publish 21.7 by 15.7 by 7.9 inches; the depth is what kills them on the test, not the length. Emirates publishes 21.7 by 15 by 8.7, just shy on length and just shy on depth. flydubai publishes 21.7 by 15 by 7.9, failing on the same two axes. Air France and KLM both publish 55 by 35 by 25 cm, which works out to 21.7 by 13.8 by 9.8 inches. The 35 cm width is what gets them: a 14-inch standard bag is just over their 13.8. The same width problem catches IndiGo, Azul, and the now-deduped GOL Linhas Aéreas (Brazil’s two big carriers map their cabin rule to the European 55 by 35 by 25 standard). WestJet’s published rule reads 22 by 9 by 14 with width and depth swapped relative to most other carriers; either way, a standard 14-inch-wide bag exceeds the published 9-inch width. Spring Airlines is the strictest in the dataset at 12 inches on the longest dimension, with a 7 kg (15.4 lb) weight cap that would fail a loaded carry-on regardless.&lt;/p&gt;
&lt;p&gt;A side fact worth filing away: 25 of 75 airlines block carry-on entirely in basic economy. The list runs through the European budget set (Ryanair, Pegasus, Vueling, Condor, Norwegian, Eurowings, Wizz Air, Transavia) and crosses over to the European mainline group whose cheapest fare also strips the cabin allowance (Air France, KLM, SWISS, SAS, plus a handful of others). Add WestJet, Air Canada, Aeromexico, GOL, and a stack of US ULCCs, and the count is one in three airlines tested. On those airlines the suitcase that fits the rule cannot board unless you upgrade the fare. The published rule is gateable. The gate just is not open.&lt;/p&gt;
&lt;h2 id=&quot;i-expected-the-post-to-end-there&quot;&gt;I expected the post to end there&lt;/h2&gt;
&lt;p&gt;While running the analysis I noticed every airline record had two carry-on fields, not one. The personal item. I expected this to be a copy-paste of the carry-on data, just with smaller numbers. It was not.&lt;/p&gt;
&lt;p&gt;Of the 75 airlines, &lt;strong&gt;54 publish personal item dimensions&lt;/strong&gt;. &lt;strong&gt;21 do not.&lt;/strong&gt; That includes Delta, the largest US carrier by revenue. Delta’s published rule says only “must fit under the seat in front of you” and lists examples: purses, small backpacks, laptop bags. There is no length, no width, no depth. Alaska and Hawaiian leave their personal item dimensions unpublished too. The rest of the unpublished group skews international: Cathay Pacific, ANA, Japan Airlines, Singapore, Korean, Emirates, Etihad, Qatar, Air New Zealand, and a handful of South American and Asian budget carriers. The personal item rule for many of the largest airlines in the world is, in writing, “vibes.” American (18 by 14 by 8) and United (17 by 10 by 9) are the US legacies that do publish, and they are the exception, not the norm.&lt;/p&gt;
&lt;p&gt;Among the 54 carriers that do publish personal item dimensions, the range is dramatic. The smallest published allowance is a three-way tie at 723 cubic inches. AirAsia, Condor, and Scoot each publish 15.7 by 11.8 by 3.9 inches, a thin folio shape. IndiGo, India’s largest domestic carrier, is the smallest by single longest dimension at 13.8 by 9.8 by 5.9, a small box about the size of a paperback novel laid flat (volume 798 cubic inches). Volaris in Mexico allows 17.7 by 13.7 by 9.8 inches, the largest published rule in the dataset at 2,376 cubic inches. The volume difference between Volaris and the smallest cluster is more than three to one.&lt;/p&gt;
&lt;p&gt;The European mainline carriers cluster tightly at 40 by 30 by something cm, with most heights pinned at 15 cm (5.9 inches). Air France, KLM, Lufthansa, SAS, SWISS, Austrian, Finnair, Turkish, TAP, Iberia, ITA, Virgin Atlantic, and Pegasus all sit on or near that line. The 5.9-inch depth is the trap. A typical small backpack is 5 to 8 inches deep when packed; the published European personal item slot just barely accepts the slimmer end of that range, and only when unpacked enough to compress. The three carriers that cluster at the slimmest depth (3.9 inches) are AirAsia, Condor, and Scoot. Two of the three are budget carriers; one is a Lufthansa-owned leisure brand. The slim folio is not a regional pattern; it is a budget pattern dressed up as one.&lt;/p&gt;
&lt;p&gt;The published personal item dimensions are widest at the US ULCCs and a few other carriers: American, Spirit, Saudia, and Viva Aerobus all publish 18 by 14 by 8 inches. Frontier publishes the same numbers in a different order (14 by 18 by 8). JetBlue and Breeze are similar at 17 by 13 by 8. Southwest is an outlier in shape: 18.5 by 8.5 by 13.5 inches, narrow but tall. United at 17 by 10 by 9 is on the smaller end of the US-published group; Delta, Alaska, and Hawaiian do not publish at all. The contrast between the European 15.7 by 11.8 by 5.9 rule and the American 18 by 14 by 8 rule is roughly two to one in volume.&lt;/p&gt;
&lt;p&gt;If you want to check your specific bag against a specific airline, the data above powers a free checker at &lt;a href=&quot;https://travelvient.com/tools/widgets/carry-on-size/&quot;&gt;travelvient.com/tools/widgets/carry-on-size&lt;/a&gt;. It uses the same JSON file and updates on the same cadence.&lt;/p&gt;
&lt;h2 id=&quot;what-this-data-does-not-capture&quot;&gt;What this data does not capture&lt;/h2&gt;
&lt;p&gt;None of the above accounts for gate enforcement. The published rule is the floor, not the ceiling. A bag that meets every published number can still get rejected at the gate. A bag that fails by half an inch can fly fine for years.&lt;/p&gt;
&lt;p&gt;Ryanair will measure your bag and weigh it before you board. They have to: the fees on non-compliant bags are how the airline makes money. Lufthansa rarely measures and almost never weighs. American gate agents will eyeball most bags and only test in the sizer if the bag clearly looks oversized or the flight is full. The same airline can be strict at one station and loose at another. Summer routes to Europe are stricter than winter routes to anywhere. The flight that ramps up after a missed connection is stricter than the early morning bank. Whether your bag flies often depends on which side of someone’s bad day you arrive on.&lt;/p&gt;
&lt;p&gt;The dataset is the scoreboard. The actual game is played at the jet bridge by a person whose mood you cannot predict. The gate moment I started this post with, where the bag was less than an inch off and the agent said no, is the part the data cannot reach.&lt;/p&gt;
&lt;h2 id=&quot;why-i-built-the-dataset&quot;&gt;Why I built the dataset&lt;/h2&gt;
&lt;p&gt;I built this because I needed structured airline data for the comparison pages on my travel site. There are 71 airline-vs-airline pages that all needed bag fees, carry-on dimensions, basic-economy rules, and a few other fields. Pulling those numbers from prose every time was a non-starter. Once it was structured, exposing it as a public tool was the obvious next step. The widget powering the bag size checker is the same JSON file behind this post and behind the comparison pages. There is one source of truth, and it gets refreshed on a 30-day cadence against the airlines’ own published policy pages.&lt;/p&gt;
&lt;p&gt;That is also why this post can be specific. Every number above traces to a record in the file, and every record has a &lt;code&gt;lastVerified&lt;/code&gt; date and a &lt;code&gt;sourceUrl&lt;/code&gt;. The CSV linked at the top has all of that.&lt;/p&gt;
&lt;h2 id=&quot;what-i-am-still-figuring-out&quot;&gt;What I am still figuring out&lt;/h2&gt;
&lt;p&gt;The unanswered question is whether the airlines that do not publish personal item dimensions are stricter or looser at the gate than the ones that do. The Delta-style “fits under the seat” rule is permissive on paper and may translate to permissive in practice. Or it may not. I do not have that data. The next analysis I want to run is a sample of routes per airline at the gate, with a real bag, watching what gets through and what does not. That is a longer project and probably needs help from people who fly more than I do.&lt;/p&gt;
&lt;p&gt;If you have a story about a personal item that got rejected (or one that should have been rejected and breezed through), I want to hear it. The stories are the part of this dataset that is missing.&lt;/p&gt;</content:encoded><category>data</category><category>airlines</category><category>carry-on</category><category>personal-item</category><category>travel</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>Building a Layover Calculator That Knows Every Terminal at JFK</title><link>https://travelvient.com/blog/building-connection-time-calculator/</link><guid isPermaLink="true">https://travelvient.com/blog/building-connection-time-calculator/</guid><description>How I built a connection time calculator covering 70 airports with pairwise terminal transfers, customs buffers, and a five-factor assessment algorithm.</description><pubDate>Mon, 27 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I already had 70 airports in the database. Each one had terminals, TSA wait times, lounges, ground transport, and layover guidance. The data was there because we built &lt;a href=&quot;https://travelvient.com/tools/airports/&quot;&gt;airport guides&lt;/a&gt; a few weeks earlier, and the research had gone deep. Minimum connection times were sitting in every airport record. The question “is my layover long enough?” was answerable with the data I already had.&lt;/p&gt;
&lt;p&gt;So I built a calculator that answers it. Not a lookup table, not a blog post that says “most experts recommend 90 minutes.” A tool that takes your airport, your connection type, your terminal, and your layover duration, then tells you whether to relax or run.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://travelvient.com/tools/connection-time/&quot;&gt;connection time calculator&lt;/a&gt; returns one of four verdicts: below MCT (the airline will not sell this connection on a single ticket), tight (meets MCT but leaves thin margins), comfortable (well above the suggested buffer), or long enough to leave the airport (6+ hours at airports with good city center access).&lt;/p&gt;
&lt;p&gt;It factors in five things: the airport’s published MCT for your connection type (domestic-to-domestic, international-to-domestic, etc.), the terminal transfer time if you are changing terminals, customs and immigration wait time if arriving internationally, TSA re-screening if you leave the secure area, and checked-bag recheck time if you have bags on an international arrival.&lt;/p&gt;
&lt;p&gt;The full page has a sortable comparison table of all 70 airports, 13 real-world scenario cards (“Is 60 minutes enough at JFK domestic-to-domestic?” with a verdict and reasoning), customs wait times broken out by region, and a 14-question FAQ. The calculator itself also ships as a &lt;a href=&quot;https://travelvient.com/tools/widgets/connection-time/&quot;&gt;free embeddable widget&lt;/a&gt; for travel blogs.&lt;/p&gt;
&lt;h2 id=&quot;how-i-worked-with-claude-on-this-one&quot;&gt;How I worked with Claude on this one&lt;/h2&gt;
&lt;p&gt;This was a tight back-and-forth in Claude Code. I described a section, Claude built it, I reviewed and redirected, repeat. The pace was fast because the widget infrastructure already existed from the &lt;a href=&quot;https://travelvient.com/blog/building-embeddable-carry-on-widget/&quot;&gt;carry-on&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/building-checked-bag-fee-widget/&quot;&gt;checked bag fee&lt;/a&gt; widgets. The iframe resize handshake, the embed page template, the customizer layout with live preview, the URL parameter validation patterns: all of that carried over.&lt;/p&gt;
&lt;p&gt;The new work was the assessment algorithm, the enrichment data layer (terminal transfers and customs buffers for every airport), and bulking up the tool page from a bare calculator to 770+ lines with depth. I wrote a handoff document specifying seven scope items and the patterns to copy from sibling tool pages. Claude executed against it one section at a time while I verified data accuracy and caught theme bugs.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;Same as the other widgets: Astro 5.x, fully static, Cloudflare Pages. The widget component lives at &lt;code&gt;src/components/embed/ConnectionTimeWidget.astro&lt;/code&gt; with all CSS and JavaScript inline. Airport data gets compressed into short keys before serializing to a JSON script tag at build time:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; airports&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; allAirports&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  slug&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;slug&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  iata&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;iata&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  city&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;city&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  tc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;terminalCount&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  terms&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;terminals&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;t&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; t&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  mct&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    dd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;minConnectionTimes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;domesticToDomesticMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    di&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;minConnectionTimes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;domesticToIntlMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    id&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;minConnectionTimes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;intlToDomesticMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    ii&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;minConnectionTimes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;intlToIntlMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    ac&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;minConnectionTimes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;airsideConnected&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    ts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;minConnectionTimes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;transferSystem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  tsa&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;peak&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;tsa&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;typicalWaitMinPeak&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;offpeak&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;tsa&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;typicalWaitMinOffPeak&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  enriched&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;hasEnrichment&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;slug&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each airport has about 50 fields in the full data model. The widget needs 15. The compression cuts the serialized payload roughly in half, which matters when you are shipping 70 airports inline.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-the-enrichment-data-layer&quot;&gt;The hard part: the enrichment data layer&lt;/h2&gt;
&lt;p&gt;The trickiest part of the entire build was not the algorithm or the UI. It was the data underneath.&lt;/p&gt;
&lt;p&gt;Every airport already had a minimum connection time. But MCT alone does not tell you much. A 45-minute MCT at LAX means something very different depending on whether you are walking between gates in Terminal 4 or taking a shuttle from TBIT to Terminal 7 and re-clearing TSA. I needed pairwise terminal transfer data for multi-terminal airports, and customs wait estimates that were airport-specific rather than “30 minutes everywhere.”&lt;/p&gt;
&lt;p&gt;The result was &lt;code&gt;connection-enrichment.json&lt;/code&gt;, 516 lines of structured data covering all 70 airports:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;dfw&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    &amp;quot;terminalTransfers&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;from&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal A&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal B&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;minutes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;8&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Skylink&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;airside&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;from&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal A&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal C&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;minutes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;10&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Skylink&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;airside&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;from&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal A&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal D&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;minutes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;12&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Skylink&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;airside&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;from&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal C&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;to&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Terminal D&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;minutes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;6&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Skylink&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;airside&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    &amp;quot;customs&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      &amp;quot;typicalMinPeak&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;35&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      &amp;quot;typicalMinOffPeak&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;15&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      &amp;quot;globalEntryMin&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;5&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      &amp;quot;notes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;International arrivals clear customs in Terminal D.&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each transfer record captures the terminal pair, the transfer time in minutes, the mode (Skylink, Plane Train, AirTrain, shuttle bus, underground train, walking), and whether you stay airside. That last field is critical. If &lt;code&gt;airside&lt;/code&gt; is false, you leave the secure area and need TSA re-screening, which adds 10 to 35 minutes depending on the airport and time of day.&lt;/p&gt;
&lt;p&gt;The assessment function layers all five factors into a single recommended buffer:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; recommendedBufferMin&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;max&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;mctMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;terminalTime&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 30&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;  +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; customsBuffer&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;  +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; securityBuffer&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;  +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; bagRecheckBuffer&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Math.max&lt;/code&gt; is doing real work. At some airports, the terminal transfer time plus a 30-minute cushion exceeds the published MCT. At others, the MCT already accounts for the transfer. Taking the larger of the two prevents the calculator from recommending less time than the airport’s published MCT.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-surprised-me&quot;&gt;Where Claude surprised me&lt;/h2&gt;
&lt;p&gt;The enrichment data. I expected to spend a full day researching terminal transfers and customs waits for 70 airports. Claude produced the entire 516-line JSON file in one pass: pairwise transfers for the top 20 hubs (DFW has 9 terminal pairs, JFK has 10, LAX has 9), customs peak and off-peak estimates for all 70 airports, Global Entry times, and notes explaining the specifics at each airport.&lt;/p&gt;
&lt;p&gt;The data was not perfect. I spot-checked a dozen airports against official sources and found the numbers were in the right range. JFK’s T1-to-T4 AirTrain transfer at 15 minutes matched. ATL’s Plane Train at 15 minutes between domestic and international was right. CDG’s CDGVAL times were reasonable. The customs estimates tracked with CBP’s published wait time data for US airports.&lt;/p&gt;
&lt;p&gt;I was prepared to write all of this by hand. Claude doing it in one shot saved hours, and the structure it chose (pairwise terminal pairs with an airside flag per transfer) was exactly the schema I would have designed. That does not always happen. Sometimes Claude picks a schema that works but is awkward to query. This time it nailed the access pattern.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-fell-short&quot;&gt;Where Claude fell short&lt;/h2&gt;
&lt;p&gt;Dark mode. Twice.&lt;/p&gt;
&lt;p&gt;The first problem was in the tool page itself. Claude used hardcoded Tailwind color classes throughout: &lt;code&gt;text-emerald-400&lt;/code&gt; for success badges, &lt;code&gt;text-rose-400&lt;/code&gt; for danger, &lt;code&gt;text-amber-400&lt;/code&gt; for warnings. These look fine in dark mode. They look terrible in light mode because the 400-weight palette is designed for dark backgrounds. The site uses a &lt;code&gt;data-theme&lt;/code&gt; attribute on the HTML element, not &lt;code&gt;prefers-color-scheme&lt;/code&gt;, so these classes need to adapt per theme.&lt;/p&gt;
&lt;p&gt;I caught this when I toggled the site to light mode and saw pale-on-white text everywhere. Claude replaced all the hardcoded classes with theme-aware CSS using &lt;code&gt;[data-theme=&amp;quot;dark&amp;quot;]&lt;/code&gt; and &lt;code&gt;[data-theme=&amp;quot;light&amp;quot;]&lt;/code&gt; selectors. That fix was straightforward.&lt;/p&gt;
&lt;p&gt;The second problem was subtler. The widget renders inline on the tool page (not in an iframe), and it has its own theme system with CSS custom properties. Claude’s initial implementation only checked for a &lt;code&gt;?theme=&lt;/code&gt; URL parameter or fell back to &lt;code&gt;prefers-color-scheme&lt;/code&gt;. It never looked at the site’s &lt;code&gt;data-theme&lt;/code&gt; attribute. So you could toggle the site to light mode, the entire page would flip, and the calculator widget would stay dark.&lt;/p&gt;
&lt;p&gt;The fix was a MutationObserver that watches the HTML element for &lt;code&gt;data-theme&lt;/code&gt; changes:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; siteTheme&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;documentElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getAttribute&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;data-theme&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;siteTheme&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setAttribute&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;data-theme&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;siteTheme&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; obs&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; MutationObserver&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; t&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;documentElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getAttribute&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;data-theme&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;t&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setAttribute&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;data-theme&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;t&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  else&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;removeAttribute&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;data-theme&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;obs&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;observe&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;documentElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  attributes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  attributeFilter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;data-theme&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern syncs instantly when the user clicks the theme toggle. It also turned out the same bug existed in the three older widgets (carry-on, bag fit, checked bag fees), so we retrofitted the fix into all four. Claude should have caught this during the initial build. The theme system is not new, and the &lt;code&gt;data-theme&lt;/code&gt; attribute is visible in the site’s global CSS. But the widget’s self-contained design (its own CSS custom properties, its own theme logic) meant Claude treated it as isolated from the host page’s theme. In iframe mode, that is correct. In inline mode, it is not.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong-overall&quot;&gt;What went wrong overall&lt;/h2&gt;
&lt;p&gt;The commit was too big. The entire feature landed in a single 2,874-line commit: the widget component, the enrichment data, the full tool page, the embed page, the customizer, the theme fixes across four widgets, the index page cards. That is too much surface area to review in one pass, and it makes &lt;code&gt;git bisect&lt;/code&gt; useless if something breaks later.&lt;/p&gt;
&lt;p&gt;The reason it happened was velocity. The back-and-forth with Claude was fast enough that breaking for a commit felt like an interruption. The handoff document laid out seven scope items, and we knocked them out sequentially without stopping to checkpoint. That worked for shipping speed. It did not work for reviewability.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The calculator is live at &lt;a href=&quot;https://travelvient.com/tools/connection-time/&quot;&gt;travelvient.com/tools/connection-time&lt;/a&gt; with the full depth pass: sortable MCT comparison table, 13 scenario cards, customs-by-region panel, best and worst airports for connections, methodology section, and 14-question FAQ. The &lt;a href=&quot;https://travelvient.com/tools/widgets/connection-time/&quot;&gt;embeddable widget&lt;/a&gt; is also live with the same customization options as the other three widgets: light/dark/auto themes, accent color, corner radius, compact mode, and URL parameters to pre-select the airport and connection type.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;Commit after each scope item, not after all seven. The handoff document had clean boundaries: enrichment data, widget component, tool page sections, embed page, customizer, theme fixes. Each one was independently shippable. Batching them into one commit saved no time and made the code review harder. Claude and I both knew there were seven items. We should have committed seven times.&lt;/p&gt;
&lt;p&gt;Establish the widget theme contract before building any more widgets. The MutationObserver pattern should have existed from the carry-on widget. Every widget we build inline will need to sync with &lt;code&gt;data-theme&lt;/code&gt;. Retrofitting four widgets at once is a sign that this should be a shared utility or at least a documented convention, not something each widget reinvents.&lt;/p&gt;
&lt;p&gt;Test the enrichment data against primary sources before building UI on top of it. Claude’s data was close enough that I did not catch any errors in my spot checks, but “close enough” is a dangerous bar for a tool people use to decide whether they will make their flight. I should have run a systematic verification pass against CBP wait time data and airport websites before the data went live, not after.&lt;/p&gt;</content:encoded><category>astro</category><category>javascript</category><category>travel-tools</category><category>embeds</category><category>devlog</category><category>built-with-claude</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>Forbes Featured the Travel Vient Carry-On Tools</title><link>https://travelvient.com/blog/forbes-mentioned-our-carry-on-tools/</link><guid isPermaLink="true">https://travelvient.com/blog/forbes-mentioned-our-carry-on-tools/</guid><description>Forbes travel columnist Christopher Elliott featured the Travel Vient carry-on size and bag fit checker tools in his Summer 2026 Digital Survival Kit.</description><pubDate>Sat, 25 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A small but quietly meaningful thing happened today. I am &lt;a href=&quot;https://travelvient.com/about/caden/&quot;&gt;Caden Sorenson&lt;/a&gt;, the developer behind Travel Vient, and Forbes travel columnist Christopher Elliott published &lt;a href=&quot;https://www.forbes.com/sites/christopherelliott/2026/04/25/heres-your-summer-2026-digital-survival-kit-for-flight-delays-and-cancellations/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;Here’s Your Summer 2026 Digital Survival Kit For Flight Delays And Cancellations&lt;/a&gt;, a roundup of the apps and tools he recommends travelers use this summer. Two of the Travel Vient tools made the cut.&lt;/p&gt;
&lt;p&gt;In the “Lost luggage tracker and security tools” section, Elliott wrote:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Sorenson built a pair of tools that let travelers enter bag dimensions and see instantly which of 75+ airlines will accept it as a carry-on or personal item. Every entry is manually verified against airline published policy.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The Forbes piece links to the Travel Vient homepage, but the two tools the column is actually about are the &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/&quot;&gt;carry-on size checker&lt;/a&gt; and the &lt;a href=&quot;https://travelvient.com/tools/checked-bag-fees/&quot;&gt;checked bag fee calculator&lt;/a&gt;. Both pull from the same hand-curated database of 75+ airlines, and both are free with no signup.&lt;/p&gt;
&lt;p&gt;The line I appreciated most was “every entry is manually verified against airline published policy.” The reality is a bit more nuanced: most of the data comes straight from official airline sources that are straightforward to cite, and we spot-check the entries that are harder to verify or where published policies are ambiguous. It is not a full manual audit of every row, but it is a lot more diligence than scraping and hoping for the best. Gate agents do not care that a third-party aggregator said your bag was fine, so getting the numbers right matters. The site has a &lt;a href=&quot;https://travelvient.com/about/methodology/&quot;&gt;methodology page&lt;/a&gt; that walks through the sourcing process and how policy changes get caught.&lt;/p&gt;
&lt;p&gt;Christopher was looking for sources on travel tools, and I pointed him to a few options including Travel Vient. He tried them out and liked them enough to include them in the piece by name.&lt;/p&gt;
&lt;p&gt;If you came here from the Forbes piece: welcome. The &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/&quot;&gt;carry-on size checker&lt;/a&gt; is the right starting point if you are trying to figure out whether your bag will fit, and the &lt;a href=&quot;https://travelvient.com/tools/checked-bag-fees/&quot;&gt;checked bag fee calculator&lt;/a&gt; is the right one if you are trying to figure out what a trip will cost you in baggage. If you run a travel blog, the &lt;a href=&quot;https://travelvient.com/tools/widgets/&quot;&gt;embeddable widgets&lt;/a&gt; are free to drop into any post.&lt;/p&gt;</content:encoded><category>press</category><category>milestones</category><category>travel-tools</category><category>carry-on</category><category>indie</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Let Claude Design My Homepage Hero and Shipped What It Built</title><link>https://travelvient.com/blog/designing-homepage-hero-with-claude/</link><guid isPermaLink="true">https://travelvient.com/blog/designing-homepage-hero-with-claude/</guid><description>I gave Claude Design six &apos;decide for me&apos; answers and it came back with a three-layer canvas flight animation. Two concepts, one conversation.</description><pubDate>Fri, 24 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I had been staring at the homepage hero for a while. It was fine. Clean text, a couple of CTA buttons, the usual indie-dev landing page. But for a site about travel tools, it felt static. The homepage for a site that tracks flight routes and airline data should probably look like it knows what a flight route is.&lt;/p&gt;
&lt;p&gt;I did not have a design in mind. I barely had a direction. I knew I wanted something animated, something that said “travel” without being a stock photo of a suitcase, and something that would not tank my Lighthouse score. That was the whole brief.&lt;/p&gt;
&lt;p&gt;So I opened Claude Design and gave it basically nothing to work with.&lt;/p&gt;
&lt;h2 id=&quot;the-decide-for-me-conversation&quot;&gt;The “decide for me” conversation&lt;/h2&gt;
&lt;p&gt;Claude Design starts by asking a battery of questions: what kind of animation, what tone, whether you are open to layout changes, how interactive you want it, how content should integrate. Normal design-tool intake stuff.&lt;/p&gt;
&lt;p&gt;I answered all six questions the same way: “Decide for me.”&lt;/p&gt;
&lt;p&gt;That sounds lazy, and it sort of was. But I was genuinely curious what would happen if I handed creative direction entirely to the AI and just played the role of someone with veto power. No mood boards, no reference links, no “I want it to look like the Stripe homepage.” Just: here is my site, here is my content, go.&lt;/p&gt;
&lt;p&gt;Claude came back with a specific, opinionated concept: a layered world map with animated flight paths arcing between real destinations, plane sprites traveling the routes, and destination pins that pulse when planes arrive. It committed to a design system before writing a single line of code. Deep charcoal background, amber accent at &lt;code&gt;#f5b82e&lt;/code&gt;, geometric plane silhouettes, mouse parallax across three depth layers.&lt;/p&gt;
&lt;p&gt;The fact that it committed to a design system first, before building anything, was the moment I started paying attention. That is not how I expected a “decide for me” prompt to go.&lt;/p&gt;
&lt;h2 id=&quot;two-concepts-not-one&quot;&gt;Two concepts, not one&lt;/h2&gt;
&lt;p&gt;After I saw the first prototype and told Claude I liked it, I asked for a second option. Not because the first one was bad. I just wanted to see if it could produce something genuinely different, or if it would give me a variation on the same idea.&lt;/p&gt;
&lt;p&gt;It proposed a split-flap departure board. The old mechanical kind you used to see in airports, where characters flip through the alphabet until they land on the right letter. Rows would cycle through real routes and airlines from my content, with status columns flashing “BOARDING” and “DELAYED” in amber and red. Pure DOM with CSS 3D transforms for the flap physics.&lt;/p&gt;
&lt;p&gt;That was a legitimately different concept. Not a reskin, not a color swap. A completely different visual metaphor with a different rendering approach (DOM vs. canvas) and a different emotional register. The world map feels atmospheric and exploratory. The departure board feels tactile and data-forward.&lt;/p&gt;
&lt;p&gt;The departure board had problems, though. The CSS for the split-flap cells was wrong on the first pass. The character positioning inside the half-flaps broke because &lt;code&gt;rotateX(180deg)&lt;/code&gt; on the back panel interacts with &lt;code&gt;bottom: 0&lt;/code&gt; positioning in a way that is not intuitive. Claude had to reason through the 3D transform chain multiple times, talking itself through “at animation start the parent has rotateX(0), the front panel faces the viewer showing the top half” and so on. It eventually got the math right, but it took several rounds.&lt;/p&gt;
&lt;p&gt;The board also overflowed its container on mid-size viewports. Claude set the breakpoint at 900px but the board was still too wide at 924px. It had to bump the breakpoint to 1100px and tighten cell sizes. These are the kind of layout issues that feel trivial in hindsight but burn real iteration cycles when you are going back and forth with a tool.&lt;/p&gt;
&lt;h2 id=&quot;what-i-shipped&quot;&gt;What I shipped&lt;/h2&gt;
&lt;p&gt;I picked the world map. The departure board was cool, but the map felt more like a homepage and less like a feature demo.&lt;/p&gt;
&lt;p&gt;The engine Claude built is a vanilla JavaScript IIFE that draws on three stacked &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; elements. No animation libraries, no framework code. Just &lt;code&gt;requestAnimationFrame&lt;/code&gt; and the Canvas 2D API.&lt;/p&gt;
&lt;p&gt;The continents are not loaded from an image or SVG. They are approximated with 23 rotated ellipses, and a function checks whether any given lat/lng coordinate falls inside one:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;javascript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; LAND_BLOBS&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;100&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cy&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;45&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;40&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;22&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rot&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; },   &lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// North America&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;90&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,   &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cy&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;55&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;48&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;18&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rot&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.05&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; },  &lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// Europe/Asia&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // ... 21 more blobs&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; isLand&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;lng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;lat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; LAND_BLOBS&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; LAND_BLOBS&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; dx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; lng&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;dy&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; lat&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cy&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; c&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;cos&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rot&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;sin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rot&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; x&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; dx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; c&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; dy&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; dx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; s&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; dy&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; c&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ((&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ry&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is not geographically accurate. It is stylized enough to read as “world map” while staying under 30 lines of code. No GeoJSON, no tile server, no external assets. I thought Claude would reach for a library or a data file for the continents. The blob approach was a genuine surprise.&lt;/p&gt;
&lt;p&gt;Flight paths use quadratic Bezier curves with a lifted control point to simulate great-circle arcs:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;javascript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; dx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;dy&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; len&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;hypot&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dy&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; mx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; my&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; nx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dy&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; len&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ny&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; dx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; len&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; lift&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;len&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0.35&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;180&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; sign&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; ny&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;c&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;mx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; nx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; lift&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; sign&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;my&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; ny&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; lift&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; sign&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lift is capped at 180 pixels so long-haul routes (like JFK to NRT) do not arc off the top of the screen. Short routes get proportionally flatter arcs. That cap was something I would have had to debug myself if Claude had not thought of it.&lt;/p&gt;
&lt;p&gt;The parallax runs at three depth levels. The world dots shift at 14px, the routes and planes at 22px. Mouse position is smoothly interpolated each frame so the motion feels fluid rather than jittery:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;javascript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;mouse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;mouse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;tx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;mouse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0.06&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;cfg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parallax&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;mouse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;py&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;mouse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ty&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;mouse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;py&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0.06&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;cfg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parallax&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The plane sprites are drawn purely with &lt;code&gt;moveTo&lt;/code&gt;/&lt;code&gt;lineTo&lt;/code&gt; calls. No images, no SVG embeds. About 12 lines of path commands per plane, with a &lt;code&gt;shadowBlur&lt;/code&gt; glow behind them in amber:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;javascript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;shadowColor&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; PAL&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;accent&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;shadowBlur&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 8&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;fillStyle&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; PAL&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;accent&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;beginPath&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;moveTo&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;5&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;lineTo&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1.6&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;lineTo&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;lineTo&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1.6&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;closePath&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;fill&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tiny geometric silhouettes that read as airplanes at the scale they are drawn. If you zoom in they look like arrowheads. At normal size they work.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-surprised-me&quot;&gt;Where Claude surprised me&lt;/h2&gt;
&lt;p&gt;The animation quality was better than what I would have built myself, full stop. I am not a canvas animation person. I can write a &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop and draw rectangles, but the layered approach with separate canvases for world/paths/planes, the smooth parallax interpolation, the pulse train trailing behind each plane, the destination pins that glow when planes arrive or depart: that is a level of polish I would not have reached on my own in a reasonable timeframe.&lt;/p&gt;
&lt;p&gt;The engine also handles &lt;code&gt;prefers-reduced-motion&lt;/code&gt; by drawing a single static frame instead of running the RAF loop, and uses an &lt;code&gt;IntersectionObserver&lt;/code&gt; to skip rendering when the hero scrolls out of view. Those are the accessibility and performance details that I know I should add but often skip when I am prototyping. Claude included them in the first pass.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-fell-short&quot;&gt;Where Claude fell short&lt;/h2&gt;
&lt;p&gt;The split-flap departure board (the concept I did not ship) needed multiple rounds of CSS fixes. The 3D transform math for the flap animation was wrong initially. Claude had to talk itself through the rotation chain step by step, and even then it was not confident the fix was correct until it could visually verify. That kind of spatial reasoning about nested 3D transforms is clearly still hard.&lt;/p&gt;
&lt;p&gt;The board also had a responsive layout bug that took two attempts to fix. Claude set the grid breakpoint at 900px, but the board was still too wide at viewports just above that threshold. These are not catastrophic failures, but they are the kind of thing that makes you realize the tool is not a replacement for testing in a browser at multiple widths.&lt;/p&gt;
&lt;p&gt;Claude Design also had persistent file loading issues during the session. It created the HTML prototype files correctly, but kept failing to load them for verification. It tried renaming files, inlining scripts, and retrying, but never fully diagnosed the issue. The prototypes worked fine when I opened them manually. This was friction, not a design problem, but it ate time.&lt;/p&gt;
&lt;h2 id=&quot;the-hardest-part&quot;&gt;The hardest part&lt;/h2&gt;
&lt;p&gt;Choosing between the two concepts was genuinely the hardest decision. Both were good for different reasons. The world map felt like a homepage. The departure board felt like a product. I went with the map because the site’s brand is more “explore the world” than “check the data,” but I could see the departure board working well as a section on an airline comparison page someday.&lt;/p&gt;
&lt;p&gt;Getting the prototype from Claude Design into my actual Astro site was also nontrivial. Claude Design outputs self-contained HTML files. My site uses Astro components, Tailwind, and a specific layout system. Splitting the prototype into &lt;code&gt;HeroFlightAnimation.astro&lt;/code&gt; (328 lines of markup) and &lt;code&gt;flight-engine.js&lt;/code&gt; (497 lines of vanilla JS) was its own task. Claude Code handled that translation in a separate session.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;First, I would not answer every question with “decide for me.” It worked here because I was genuinely open to anything, but Claude makes better choices when you give it constraints. “Atmospheric, not playful” or “canvas only, no DOM animation” would have saved the departure board detour. Creative freedom sounds good in theory, but constraints produce better first drafts.&lt;/p&gt;
&lt;p&gt;Second, I would ask for both concepts up front instead of asking for a second one after seeing the first. Claude Design had to rebuild all the shared scaffolding (hero copy, nav, tweaks panel) for the second prototype. If I had asked for two directions from the start, it could have shared more structure between them.&lt;/p&gt;
&lt;p&gt;Third, I would pair Claude Design with Claude Code from the beginning instead of treating them as separate phases. The prototype-to-production translation was a full work session on its own. If the prototype had been built as an Astro component from the start, that step disappears. The tradeoff is that Claude Design’s sandbox is simpler than a real project, which probably helps it iterate faster on the visual side. But for a site where I know the stack, I would rather iterate slower and skip the port.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/&quot;&gt;the live homepage&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/&quot;&gt;more devlogs&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>claude-design</category><category>canvas</category><category>javascript</category><category>animation</category><category>devlog</category><category>built-with-claude</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/homepage-hero.DDUj84j3.png" length="0" type="image/jpeg"/></item><item><title>Building a Checked Bag Fee Calculator That Computes</title><link>https://travelvient.com/blog/building-checked-bag-fee-widget/</link><guid isPermaLink="true">https://travelvient.com/blog/building-checked-bag-fee-widget/</guid><description>Framework-free fee calculator widget. Overweight surcharges, third-bag estimates, 75+ airlines sorted by total cost, all inside an iframe.</description><pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;https://travelvient.com/blog/building-embeddable-carry-on-widget/&quot;&gt;carry-on size widget&lt;/a&gt; was a lookup tool. Pick an airline, see its dimensions. The data was static per airline, the rendering was a fixed card, and the interesting engineering was in the iframe plumbing and data pipeline underneath it. When a reader and a few travel bloggers asked for an embeddable version of our &lt;a href=&quot;https://travelvient.com/tools/checked-bag-fees/&quot;&gt;checked bag fee tool&lt;/a&gt;, I assumed it would be the same build with different data. It was not.&lt;/p&gt;
&lt;p&gt;The checked bag fee widget is a calculator. It takes four inputs (bag count, trip type, weight, linear dimensions), computes a per-airline total from six different fee fields, handles null data without lying about costs, sorts 75+ airlines by computed total, and badges the cheapest and most expensive results. That computation layer is what made this widget genuinely different from the carry-on embed, and it is where most of the interesting problems lived.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://travelvient.com/tools/widgets/checked-bag-fees/&quot;&gt;checked bag fee calculator widget&lt;/a&gt; lets a reader on any blog compare checked bag costs across 75+ airlines with their specific trip details. Select 1, 2, or 3 bags. Toggle domestic or international. Enter a weight and linear dimension if you want to catch overweight and oversize surcharges. The widget computes the total for every airline and sorts cheapest first.&lt;/p&gt;
&lt;p&gt;Each airline row shows the individual fee breakdown (first bag, second bag, international rate) plus the computed total. Airlines with free checked bags show a green “$0.” Airlines where the bag exceeds the weight limit or oversize threshold get a warning badge. The cheapest airline gets a “Cheapest” badge, the most expensive gets “Most expensive.”&lt;/p&gt;
&lt;p&gt;All of this runs inside a single iframe. No framework, no external requests after page load, under 50 KB total. A short HTML snippet to embed.&lt;/p&gt;
&lt;h2 id=&quot;how-i-worked-with-claude-on-this-one&quot;&gt;How I worked with Claude on this one&lt;/h2&gt;
&lt;p&gt;This was the fastest widget build we have done. The carry-on widget established all the infrastructure: the iframe-to-host postMessage resize handshake, the &lt;code&gt;resize.js&lt;/code&gt; listener, the URL parameter validation patterns, the inline-everything-for-zero-dependencies approach, the Astro embed page template, the widget landing page layout with the visual customizer. All of that already existed.&lt;/p&gt;
&lt;p&gt;So when I described the checked bag fee widget to Claude via Claude Code, it copied the existing patterns and had a working first version in one session. The carry-on widget was a two-day build. This one was half a day. Most of my time went into the calculator logic and the resize timing, not infrastructure.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;Same as the carry-on widget: Astro 5.x, static output, Cloudflare Pages. The widget is a standalone Astro component at &lt;code&gt;src/components/embed/CheckedBagFeesWidget.astro&lt;/code&gt; with all CSS and JavaScript inline. The airline data gets serialized into a &lt;code&gt;&amp;lt;script type=&amp;quot;application/json&amp;quot;&amp;gt;&lt;/code&gt; tag at build time, so there are no runtime fetches.&lt;/p&gt;
&lt;p&gt;The data shape is compressed the same way as the carry-on widget, mapping verbose property names to short keys to keep the payload small across 75+ airlines:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; airlines&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; allAirlines&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  .&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;filter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  .&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    slug&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;slug&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    iata&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;iata&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    region&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;region&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    cat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;category&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    first&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;firstBagUsd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    second&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;secondBagUsd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    intl&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;firstBagIntlUsd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    over70&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;overweight51to70Usd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    over100&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;overweight71to100Usd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    oversize&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;oversize63to80Usd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    wLim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weightLimitLb&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    lv&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;checkedBag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lastVerified&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ??&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lastVerified&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Six fee fields per airline instead of the carry-on widget’s three. That is where the complexity budget went.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-computing-totals-with-holes-in-the-data&quot;&gt;The hard part: computing totals with holes in the data&lt;/h2&gt;
&lt;p&gt;The core of the widget is a &lt;code&gt;computeTotal()&lt;/code&gt; function that takes an airline and the current calculator state and returns a total fee plus any warning flags. This sounds simple until you account for all the edge cases.&lt;/p&gt;
&lt;p&gt;Third bags do not have a published fee for most airlines. We estimate them at 1.5x the second bag fee, which is close to what most carriers charge. If the second bag fee is null, the third bag estimate is also null. Overweight surcharges apply at two thresholds: 51-70 lb and 71-100 lb. Bags over 100 lb are rejected by most airlines entirely. Oversize kicks in between 63 and 80 linear inches. Over 80, most airlines refuse the bag.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; computeTotal&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; firstFee&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; trip&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;international&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    ?&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;intl&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;intl&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;first&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    :&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;first&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; parts&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; flags&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bagCount&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;firstFee&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bagCount&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;second&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bagCount&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 3&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;second&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;round&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;second&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1.5&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; weight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; parseFloat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;weightInput&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;isNaN&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weight&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; weight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 100&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Exceeds weight&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    else&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 70&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;over100&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    else&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 50&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;over70&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;wLim&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; weight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;wLim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Over limit&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;some&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) { &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; p&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; }))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;total&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; total&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;total&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;total&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;total&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The critical line is the null check near the bottom. If any &lt;code&gt;parts&lt;/code&gt; entry is null, the whole total is null. This was a deliberate design decision. If we do not know the overweight surcharge for an airline and the reader entered 65 lb, showing just the base bag fee would be misleading. It would look like that airline is cheaper than it actually is. Better to show “N/A” and let the reader check the airline’s site.&lt;/p&gt;
&lt;p&gt;Claude wrote the first version of this function. I rewrote the null propagation. Claude’s original version defaulted nulls to zero, which would have ranked airlines with missing surcharge data as the cheapest options. That is exactly the kind of subtle data bug that makes a tool untrustworthy.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-surprised-me&quot;&gt;Where Claude surprised me&lt;/h2&gt;
&lt;p&gt;The badge assignment logic in the &lt;code&gt;apply()&lt;/code&gt; function. After sorting all airlines by computed total (with nulls pushed to the bottom via a 999999 sentinel value), the widget needs to label the cheapest and most expensive results. Claude handled the edge cases I would have missed:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; badge&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;item&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  badge&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;&amp;lt;span class=&amp;quot;cbf-badge cbf-badge-warn&amp;quot;&amp;gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;    +&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; item&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;join&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39; / &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;&amp;lt;/span&amp;gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;} &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;filtered&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; item&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;total&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;j&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    badge&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;&amp;lt;span class=&amp;quot;cbf-badge cbf-badge-cheap&amp;quot;&amp;gt;Cheapest&amp;lt;/span&amp;gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; lastWithTotal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; k&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; filtered&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;k&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;k&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;--&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;filtered&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;k&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;].&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;total&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lastWithTotal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; k&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;break&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;j&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; lastWithTotal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; j&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    badge&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;&amp;lt;span class=&amp;quot;cbf-badge cbf-badge-expensive&amp;quot;&amp;gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;      +&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;Most expensive&amp;lt;/span&amp;gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Warning flags take priority over cost badges. The “Most expensive” label skips airlines with null totals by walking backward from the end of the sorted list to find the last airline with a real number. If only one airline has data, neither badge shows. Claude got all of this right on the first pass. I was about to write it myself and realized it was already done.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-fell-short&quot;&gt;Where Claude fell short&lt;/h2&gt;
&lt;p&gt;The resize timing. This is the problem I called out in the interview: getting the iframe height right after the page loads, not just when a user interacts with it.&lt;/p&gt;
&lt;p&gt;The carry-on widget had a simpler version of this problem because its content height is mostly stable. Pick an airline, the card renders, send a resize. But the checked bag fee widget renders a scrollable list of 75+ airlines on load, and the list height depends on which filters are active, how many results match, and whether the calculator inputs have changed the sort order.&lt;/p&gt;
&lt;p&gt;Claude’s first approach was to fire &lt;code&gt;sendResize()&lt;/code&gt; once after &lt;code&gt;apply()&lt;/code&gt;. That works if the iframe’s content has finished painting by the time the message sends. It often had not. The iframe would load, &lt;code&gt;apply()&lt;/code&gt; would run, the resize message would fire with a stale height, and then the actual content would paint at a different height. The result was clipped content or extra whitespace depending on the timing.&lt;/p&gt;
&lt;p&gt;I ended up writing the resize timing myself. The solution is a combination of interval polling and ResizeObserver:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; pollCount&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; pollId&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; setInterval&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  sendResize&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pollCount&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 50&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;clearInterval&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pollId&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;200&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;typeof&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; ResizeObserver&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;undefined&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; ResizeObserver&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;sendResize&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;observe&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The polling catches the initial render window: 50 checks at 200ms intervals covers the first 10 seconds, which is enough for any reasonable page load. The ResizeObserver takes over after that for ongoing changes (filter toggles, search narrowing, weight input). Belt and suspenders. It is not elegant, but it works on every host page I have tested.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong-overall&quot;&gt;What went wrong overall&lt;/h2&gt;
&lt;p&gt;The fee data itself was the hardest non-code problem. Airline baggage fees are surprisingly inconsistent in how they are published. Some airlines list fees per direction, some per round trip. Some publish overweight surcharges on the same page as standard fees, some bury them in a separate “special items” page. A few airlines publish different fees depending on the booking channel (website vs airport vs phone).&lt;/p&gt;
&lt;p&gt;I had to make normalization decisions: all fees are one-way, all fees are the website/online booking price, overweight thresholds are standardized to the 51-70 and 71-100 lb brackets even when an airline uses slightly different breakpoints. These are reasonable defaults, but they mean the widget’s numbers are close approximations, not guaranteed quotes. That is why every airline row links to the official source page.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The widget is live at &lt;a href=&quot;https://travelvient.com/tools/widgets/checked-bag-fees/&quot;&gt;travelvient.com/tools/widgets/checked-bag-fees&lt;/a&gt; with the same customization options as the other two widgets: light/dark/auto themes, custom accent colors, adjustable corner radius, compact mode for sidebars, and URL parameters to pre-set the bag count, trip type, weight, and dimensions. It is also embedded in several of our own guides, including the &lt;a href=&quot;https://travelvient.com/guides/best-checked-bag-fee-calculators/&quot;&gt;best checked bag fee calculators&lt;/a&gt; roundup and the &lt;a href=&quot;https://travelvient.com/guides/how-to-add-free-travel-widgets-to-your-blog/&quot;&gt;how to add travel widgets&lt;/a&gt; tutorial.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;First, I would have written the &lt;code&gt;computeTotal()&lt;/code&gt; function test-first. The null propagation, threshold logic, and third-bag estimation have enough edge cases that a simple test harness with a few mock airlines would have caught the zero-default bug Claude introduced before I noticed it in the UI. Writing calculator logic without tests and then debugging it visually was slower than it needed to be.&lt;/p&gt;
&lt;p&gt;Second, the resize polling is a hack I should replace. A &lt;code&gt;MutationObserver&lt;/code&gt; on the list element, firing only when &lt;code&gt;innerHTML&lt;/code&gt; changes, would be more precise than polling 50 times and hoping one of those polls lands after paint. The current approach works, but it sends dozens of unnecessary postMessage calls to the host page during the first 10 seconds of load.&lt;/p&gt;
&lt;p&gt;Third, I should have standardized the fee normalization rules in the data layer, not in the widget. Right now, the normalization decisions (one-way fees, online booking price, standardized weight brackets) are baked into the data entry process and documented nowhere except my own notes. If someone else contributes airline data, they would not know which conventions to follow. That is a data pipeline problem, not a widget problem, but the widget is what exposed it.&lt;/p&gt;</content:encoded><category>javascript</category><category>travel-tools</category><category>embeds</category><category>devlog</category><category>built-with-claude</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/fees-widget.CFl7eYzd.png" length="0" type="image/jpeg"/></item><item><title>I Built a Free Embeddable Carry-On Size Widget for Travel Blogs</title><link>https://travelvient.com/blog/building-embeddable-carry-on-widget/</link><guid isPermaLink="true">https://travelvient.com/blog/building-embeddable-carry-on-widget/</guid><description>A free, no-cookies carry-on size checker any travel blog can embed with a short HTML snippet. 75+ airlines, auto-updating data, full theme customization.</description><pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Editor’s note (2026-05-17):&lt;/strong&gt; This post describes the widget’s design as of April 2026. In May 2026 we added anonymous usage analytics (widget loads, key interactions, embedding domain, country, device class) so we can see which widgets earn engagement and where to focus next. The widget still sets no cookies and shares no personal data. See the &lt;a href=&quot;https://travelvient.com/privacy/&quot;&gt;privacy page&lt;/a&gt; for the full disclosure.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A travel blogger emailed me a few weeks ago asking if she could embed our &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/&quot;&gt;carry-on size checker&lt;/a&gt; directly into one of her posts. She was writing a packing guide for budget airlines and wanted her readers to check their specific bag against each airline without leaving the page. I told her no, because the tool was a full-page Astro component with site-wide styles, a navigation bar, and a footer. It was not designed to live inside someone else’s site.&lt;/p&gt;
&lt;p&gt;That request stuck with me. I looked around for existing embeddable carry-on checkers that a travel blogger could just drop into a post. The options were bad. Most “widgets” were just affiliate links dressed up as tools. The few real ones had outdated data, tracked visitors, or required a paid account. Nobody was doing the obvious thing: a free, lightweight iframe with verified airline data that auto-updates.&lt;/p&gt;
&lt;p&gt;So I built one.&lt;/p&gt;
&lt;figure class=&quot;video-embed my-8&quot; data-astro-cid-mrk3q7f7&gt; &lt;div class=&quot;video-wrap&quot; style=&quot;aspect-ratio: 1512 / 806;&quot; data-astro-cid-mrk3q7f7&gt; &lt;video controls preload=&quot;none&quot; playsinline poster=&quot;/videos/embed-demo-poster.jpg&quot; width=&quot;1512&quot; height=&quot;806&quot; aria-label=&quot;Carry-on size embed widget demo&quot; data-astro-cid-mrk3q7f7&gt; &lt;source src=&quot;/videos/embed-demo.webm&quot; type=&quot;video/webm&quot; data-astro-cid-mrk3q7f7&gt; &lt;source src=&quot;/videos/embed-demo.mp4&quot; type=&quot;video/mp4&quot; data-astro-cid-mrk3q7f7&gt;
Your browser does not support embedded video. You can
&lt;a href=&quot;https://travelvient.com/videos/embed-demo.mp4&quot; data-astro-cid-mrk3q7f7&gt;download the MP4&lt;/a&gt;.
&lt;/video&gt; &lt;/div&gt; &lt;figcaption class=&quot;mt-3 text-sm text-ink-2&quot; data-astro-cid-mrk3q7f7&gt; &lt;strong class=&quot;text-ink&quot; data-astro-cid-mrk3q7f7&gt;Carry-on size embed widget demo&lt;/strong&gt; &lt;span class=&quot;block mt-1&quot; data-astro-cid-mrk3q7f7&gt;Picking an airline, reading carry-on and personal item dimensions, weight limits, fees, and the gate-check risk rating, all from a single drop-in iframe.&lt;/span&gt; &lt;/figcaption&gt; &lt;/figure&gt; 
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://travelvient.com/tools/widgets/carry-on-size/&quot;&gt;carry-on size embed widget&lt;/a&gt; is a drop-in carry-on bag size checker for any website. A short HTML snippet. No accounts, no API keys, no cookies.&lt;/p&gt;
&lt;p&gt;A reader picks an airline from the dropdown and sees carry-on dimensions (inches and centimeters), personal item limits, weight restrictions, the carry-on fee, and a gate-check risk rating. Every data point links back to the airline’s official baggage policy page with a “last verified” date.&lt;/p&gt;
&lt;p&gt;It covers 75+ airlines across North America, Europe, Asia-Pacific, Latin America, and the Middle East. When we update the data, every embedded widget everywhere reflects the change automatically. The site owner does nothing.&lt;/p&gt;
&lt;p&gt;Here is the widget itself, running live inside this post. Pick any airline to see exactly what a reader would see on a travel blog that had embedded it:&lt;/p&gt;
&lt;figure class=&quot;carry-on-embed-demo my-10&quot; data-astro-cid-vdnq2nkj&gt; &lt;div class=&quot;wrap&quot; data-astro-cid-vdnq2nkj&gt; &lt;iframe src=&quot;/embed/carry-on/?airline=southwest-airlines&amp;#38;theme=dark&quot; title=&quot;Live carry-on size checker widget&quot; loading=&quot;lazy&quot; height=&quot;420&quot; style=&quot;width:100%;max-width:640px;border:0;display:block;margin:0 auto;&quot; data-astro-cid-vdnq2nkj&gt;&lt;/iframe&gt; &lt;/div&gt; &lt;figcaption class=&quot;mt-3 text-center text-sm text-ink-2&quot; data-astro-cid-vdnq2nkj&gt;Live embed. Same iframe, same data, same drop-in install a blogger would use.&lt;/figcaption&gt; &lt;/figure&gt; &lt;script src=&quot;/embed/resize.js&quot; async&gt;&lt;/script&gt; 
&lt;p&gt;The widget supports light, dark, and auto (OS preference) themes. You can set a custom accent color, adjust corner radius, toggle compact mode for sidebars, and lock it to a default airline. A &lt;a href=&quot;https://travelvient.com/tools/widgets/carry-on-size/&quot;&gt;visual customizer&lt;/a&gt; on the landing page lets you configure everything and copy the embed code.&lt;/p&gt;
&lt;h2 id=&quot;how-i-worked-with-claude-on-this-one&quot;&gt;How I worked with Claude on this one&lt;/h2&gt;
&lt;p&gt;This was a pairing build. I described the concept and Claude (via Claude Code) did most of the initial implementation, but a big chunk of the work was not the widget itself. It was the data layer underneath it.&lt;/p&gt;
&lt;p&gt;We already had 75+ airlines in a JSON file, but keeping that data fresh is the real problem. Airlines change their baggage policies constantly. A checked bag fee goes up $5, a weight limit changes from 15 kg to 10 kg, a basic economy restriction gets added quietly. So Claude and I spent a significant amount of time building a manifest-driven fact refresh system that verifies data on a rolling 30-day cycle. Every fact gets checked against two independent sources before it is trusted. If the sources disagree, a third source breaks the tie. If an auto-fix happens, any comparison articles referencing that airline get flagged for prose review.&lt;/p&gt;
&lt;p&gt;We turned that entire verification system into a Claude Code skill, so I can run &lt;code&gt;/refresh-facts airlines&lt;/code&gt; and it will triage the most stale airline records, verify them against official sources and aggregators like The Points Guy and NerdWallet, and auto-fix what it can. That pipeline is what makes the embed widget trustworthy. Without it, this would just be another tool with stale data.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;Astro 5.x, static output, deployed to Cloudflare Pages. The widget is an iframe pointing at &lt;code&gt;/embed/carry-on/&lt;/code&gt;, which is a standalone Astro page with zero external dependencies. All CSS is inline and scoped to a &lt;code&gt;.va-embed&lt;/code&gt; class prefix to avoid leaking into host pages. All JavaScript is inline too. No framework, no bundler output, no external requests after page load.&lt;/p&gt;
&lt;img src=&quot;https://travelvient.com/_astro/embed-code.BEI81aZZ_NyeKP.webp&quot; alt=&quot;The embed snippet with iframe, attribution line, and resize script&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;1024&quot; height=&quot;328&quot;&gt;
&lt;p&gt;The airline data gets serialized into the widget HTML at build time via a &lt;code&gt;&amp;lt;script type=&amp;quot;application/json&amp;quot;&amp;gt;&lt;/code&gt; tag. This means the widget is a single static HTML file with everything baked in. No runtime data fetches. The total payload is under 50 KB.&lt;/p&gt;
&lt;p&gt;Claude suggested compressing the airline data for the widget by mapping verbose property names to short keys:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; airlines&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; allAirlines&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  slug&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;slug&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  ci&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;carryOn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dimensionsIn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  cc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;carryOn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dimensionsCm&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  wLb&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;carryOn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weightLimitLb&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  wKg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;carryOn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weightLimitKg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  fee&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;carryOn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;feeUsd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  ok&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;carryOn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;allowed&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  piI&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;personalItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dimensionsIn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  piC&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;personalItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dimensionsCm&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  risk&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;gateCheckRisk&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  be&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;basicEconomyRestricted&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  lv&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lastVerified&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  src&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;sourceUrl&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not revolutionary, but it cut the JSON size meaningfully when you multiply it by 75+ airlines. That was a pattern I would not have bothered with on my own for a v1.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-iframe-auto-resize&quot;&gt;The hard part: iframe auto-resize&lt;/h2&gt;
&lt;p&gt;Iframes do not auto-expand to fit their content. The widget height varies depending on the airline (some have weight limits, some do not, some do not allow carry-ons at all), the theme, and whether compact mode is on. A fixed &lt;code&gt;height=&amp;quot;420&amp;quot;&lt;/code&gt; works as a sensible default, but it leaves whitespace or clips content depending on the selection.&lt;/p&gt;
&lt;p&gt;The solution is a &lt;code&gt;postMessage&lt;/code&gt; handshake between the widget and the host page. The widget measures its own height and tells the parent:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; sendResize&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;window&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parent&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; window&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    window&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;parent&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;postMessage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      type&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;va-embed-resize&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;documentElement&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;scrollHeight&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;*&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The host page loads a 14-line script that listens for that message and adjusts the iframe height:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;window&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;addEventListener&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;message&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;e&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; d&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; e&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; d&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;type&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;va-embed-resize&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; typeof&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; d&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;number&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; frames&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelectorAll&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;iframe&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; frames&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frames&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;].&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;contentWindow&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; e&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;source&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      frames&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;].&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;style&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; d&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;px&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      break&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resize fires on initial load, on airline selection change, and via a &lt;code&gt;ResizeObserver&lt;/code&gt; for anything else that shifts the layout. I solved this one myself. Claude’s first approach was to set a generous fixed height and call it done, which would have worked but looked sloppy on most sites.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-surprised-me&quot;&gt;Where Claude surprised me&lt;/h2&gt;
&lt;p&gt;The data compression was the standout. When I described the widget concept, Claude immediately suggested compressing the property names for the embedded JSON payload and explained why: the full &lt;code&gt;airlines.json&lt;/code&gt; with verbose keys is ~9,500 lines. The widget only needs display fields, so mapping to single-letter keys and dropping unused nested objects (checked bags, special items, basic economy details) keeps the widget fast on mobile connections. It is the kind of optimization I would have deferred to v2 or never done.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-fell-short&quot;&gt;Where Claude fell short&lt;/h2&gt;
&lt;p&gt;The first version Claude built was too basic. It was functional, it showed airline data in a dropdown, but it was not something a real travel blogger would want on their site. No theme options, no custom colors, no border radius control, no compact mode. It worked, but only for one kind of site with one kind of design.&lt;/p&gt;
&lt;p&gt;I had to push Claude through several rounds of “make this actually reusable.” Custom accent colors via URL parameters. Light, dark, and auto themes with proper CSS custom properties. A radius slider. A compact mode that scales all spacing proportionally. Each round Claude executed well, but it did not anticipate that an embeddable tool needs to fit into someone else’s design system, not just look good on its own. That product thinking was on me.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong-overall&quot;&gt;What went wrong overall&lt;/h2&gt;
&lt;p&gt;The URL parameter validation was a subtle security concern. The widget reads theme, color, radius, and airline from query parameters, and those parameters get applied to CSS and DOM attributes. Claude’s initial implementation did not validate the color parameter, which could have been a vector for CSS injection. I caught it and we added a strict hex regex:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; colorParam&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; params&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;color&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;colorParam&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;^&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;[0-9a-fA-F]{6}&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;$&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;test&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;colorParam&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;style&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setProperty&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;--accent&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;#&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; colorParam&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The radius gets clamped to 0-24, the theme only accepts &lt;code&gt;&amp;#39;light&amp;#39;&lt;/code&gt; or &lt;code&gt;&amp;#39;dark&amp;#39;&lt;/code&gt;, and the airline slug is checked against the data map before use. None of these would have caused a major exploit, but shipping an embeddable widget means your code runs on someone else’s site. The bar is higher.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The widget is live at &lt;a href=&quot;https://travelvient.com/tools/widgets/carry-on-size/&quot;&gt;travelvient.com/tools/widgets/carry-on-size&lt;/a&gt; with 75+ airlines, full customization, and a visual configurator that generates the embed code. The data pipeline behind it runs on a 30-day verification cycle. Since launch, two more widgets have shipped: a &lt;a href=&quot;https://travelvient.com/tools/widgets/bag-fit/&quot;&gt;bag fit checker&lt;/a&gt; and a &lt;a href=&quot;https://travelvient.com/tools/widgets/checked-bag-fees/&quot;&gt;checked bag fee calculator&lt;/a&gt;. Browse all widgets at &lt;a href=&quot;https://travelvient.com/tools/widgets/&quot;&gt;travelvient.com/tools/widgets&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;First, I would have spec’d the customization options before writing any code. Starting with a bare widget and retrofitting theme support, custom colors, and compact mode was more work than doing it upfront. The widget essentially got rebuilt twice.&lt;/p&gt;
&lt;p&gt;Second, I would have built the data compression into the widget from the start rather than serializing the full airline objects and trimming later. Starting with a clear “widget data contract” separate from the full airline schema would have been cleaner.&lt;/p&gt;
&lt;p&gt;Third, the fact refresh system should have existed before the embed widget, not alongside it. Building an embeddable tool and a data verification pipeline at the same time meant splitting focus. The refresh system was the more important piece, and getting it right first would have made the widget launch feel less rushed.&lt;/p&gt;</content:encoded><category>astro</category><category>javascript</category><category>travel-tools</category><category>embeds</category><category>seo</category><category>devlog</category><category>built-with-claude</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/embed-hero.CykMbzu8.png" length="0" type="image/jpeg"/></item><item><title>How to Set Up a Claude Code Statusline (Step-by-Step)</title><link>https://travelvient.com/blog/building-a-claude-code-statusline/</link><guid isPermaLink="true">https://travelvient.com/blog/building-a-claude-code-statusline/</guid><description>Set up a custom Claude Code statusline in 60 seconds with /statusline. Show your /usage, /context, 5-hour reset time, and working directory as colored progress.</description><pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I’m on Claude Code Max 5x. It’s a generous plan. It is also not infinite.&lt;/p&gt;
&lt;p&gt;Three things kept catching me off guard. The 5-hour session window would fill up in the middle of a long chain of edits and suddenly I was on a cooldown I hadn’t seen coming. The 7-day rolling limit was a slower version of the same problem. And the context window would creep toward full during a deep session and I’d only notice when the responses got weirdly slow.&lt;/p&gt;
&lt;p&gt;I knew all three numbers existed somewhere. You can already get the rate limits by running &lt;code&gt;/usage&lt;/code&gt; in Claude Code, and the context window by running &lt;code&gt;/context&lt;/code&gt;. I was running both commands several times an hour, which is exactly the friction I was trying to avoid in the first place.&lt;/p&gt;
&lt;p&gt;It turns out Claude Code has a statusline feature that very few people seem to know about. It runs a shell command before every prompt and prints whatever that command returns at the bottom of your terminal. That’s the whole primitive. You get to decide what goes in it. So instead of typing &lt;code&gt;/usage&lt;/code&gt; and &lt;code&gt;/context&lt;/code&gt; on demand, I put both of them plus my working directory and my 5h reset time in the statusline and never have to ask again.&lt;/p&gt;
&lt;p&gt;The good news: you don’t have to write any bash to get this. Claude Code does it for you.&lt;/p&gt;
&lt;h2 id=&quot;how-to-set-up-a-claude-code-statusline-in-60-seconds&quot;&gt;How to set up a Claude Code statusline in 60 seconds&lt;/h2&gt;
&lt;p&gt;Inside any Claude Code session, type:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;/statusline&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That fires the built-in &lt;code&gt;statusline-setup&lt;/code&gt; agent. Then tell it, in plain English, what you want shown. Here’s roughly what I asked for:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I want my working directory, the model I’m using, the current git branch, my 5-hour usage window, my 7-day usage window, and my context window. Show the meters as severity-colored progress bars: green under 50%, yellow 50 to 80%, red over 80%. Put the reset time next to the 5-hour bar.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That single message is enough. The agent writes the bash, drops the script at &lt;code&gt;~/.claude/statusline-command.sh&lt;/code&gt;, and registers it in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; for you. Restart Claude Code, run a prompt, and the statusline shows up under the input after the first response.&lt;/p&gt;
&lt;p&gt;You can ask for whatever fields you want. The JSON the harness pipes to the script includes both rate-limit windows with reset timestamps, the context window, the working directory, and the model name. If you want the git branch, the agent will shell out to &lt;code&gt;git&lt;/code&gt; to grab it. Mix and match.&lt;/p&gt;
&lt;p&gt;Here’s what mine looks like once it’s running:&lt;/p&gt;
&lt;img src=&quot;https://travelvient.com/_astro/statusline.B7o6jDA0_Z6QUKO.webp&quot; alt=&quot;Claude Code statusline showing working directory, 5h usage bar with reset time, 7d usage bar, and context window bar, all colored by severity&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;639&quot; height=&quot;71&quot;&gt;
&lt;p&gt;Folder, 5h bar with reset time, 7d bar, context bar. The bars shift from green to yellow to red as they fill, so I can scan the line without reading numbers.&lt;/p&gt;
&lt;p&gt;If the first pass doesn’t quite land, keep talking. “Move the folder to the front.” “Drop the percent number, the bar is enough.” “Use a shorter label for the 5-hour bar.” Each round is one short message. I went four rounds before I landed mine, and I never touched the bash myself.&lt;/p&gt;
&lt;p&gt;That’s the whole setup. The rest of this post is the story of how mine came together, the bits where Claude surprised me, and &lt;a href=&quot;#the-full-claude-code-statusline-bash-script&quot;&gt;the full bash script&lt;/a&gt; if you’d rather paste it directly than have the agent rebuild it.&lt;/p&gt;
&lt;h2 id=&quot;what-the-claude-code-statusline-actually-is&quot;&gt;What the Claude Code statusline actually is&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;statusLine&lt;/code&gt; entry in your &lt;code&gt;~/.claude/settings.json&lt;/code&gt; that points at a script. Every time you hit enter on a Claude Code prompt, the harness pipes a JSON blob to that script over stdin and prints whatever your script writes to stdout.&lt;/p&gt;
&lt;p&gt;The JSON blob contains useful fields. For my purposes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cwd&lt;/code&gt;, the current working directory&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rate_limits.five_hour.used_percentage&lt;/code&gt; and &lt;code&gt;resets_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rate_limits.seven_day.used_percentage&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;context_window.used_percentage&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is all I needed.&lt;/p&gt;
&lt;h2 id=&quot;how-i-worked-with-claude-on-this-one&quot;&gt;How I worked with Claude on this one&lt;/h2&gt;
&lt;p&gt;This was the &lt;code&gt;statusline-setup&lt;/code&gt; agent end to end. I’d tell the agent what I wanted, it would write the script, I’d look at the output in my terminal, then come back and say “now change this.” Four rounds total:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;First pass: show 5h, 7d, context, cwd as percentages.&lt;/li&gt;
&lt;li&gt;“Shorter. Show just the folder, not the full path. Can we use bars instead of percentages?”&lt;/li&gt;
&lt;li&gt;“Move the folder to the front. Color the bars by severity.”&lt;/li&gt;
&lt;li&gt;“Add the reset time for the 5-hour bar.”&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I didn’t hand-edit the script at any point. Every change went through the agent. Not because I was trying to be pure about it, just because the iterations were fast enough that writing prose was quicker than diffing bash.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;bash&lt;/code&gt; + &lt;code&gt;jq&lt;/code&gt; + ANSI escape codes. That’s it.&lt;/p&gt;
&lt;p&gt;The script lives at &lt;code&gt;~/.claude/statusline-command.sh&lt;/code&gt;. The &lt;code&gt;settings.json&lt;/code&gt; entry points at it:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;statusLine&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;bash /Users/YOURNAME/.claude/statusline-command.sh&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No install step, no build, no dependencies beyond &lt;code&gt;jq&lt;/code&gt; (which most dev machines already have).&lt;/p&gt;
&lt;h2 id=&quot;the-bars&quot;&gt;The bars&lt;/h2&gt;
&lt;p&gt;I wanted the progress bars to be visually distinct from each other so I could spot trouble at a glance. Claude’s first pass had them all in the same dim color, which was technically working but useless for scanning. That was round three of corrections.&lt;/p&gt;
&lt;p&gt;The fix was severity coloring. The filled portion of each bar gets green, yellow, or red depending on how full it is. The empty portion stays dim gray.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;bar_color&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  int_pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;%.0f&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; 2&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; || &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$int_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -ge&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 81&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] 2&amp;gt;/dev/null; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;    printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;31m&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;   # red&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  elif&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$int_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -ge&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 50&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] 2&amp;gt;/dev/null; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;    printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;33m&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;   # yellow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  else&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;    printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;32m&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;   # green&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Green below 50, yellow 50 to 80, red above 80. So when I glance down and see two greens and a yellow, I’m fine. Two yellows and a red, I wrap up the current task before I lose the session.&lt;/p&gt;
&lt;h2 id=&quot;drawing-a-10-slot-bar-in-pure-shell&quot;&gt;Drawing a 10-slot bar in pure shell&lt;/h2&gt;
&lt;p&gt;The bar itself is 10 Unicode blocks, some filled, some empty. This function computes how many slots to fill, then paints them with ANSI colors:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;make_bar&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  filled&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;%.0f&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; * 10 / 100&amp;quot; &lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; bc&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -l&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; 2&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null &lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; echo&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;)&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$filled&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -gt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 10&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;filled&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;10&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$filled&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -lt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;filled&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  color&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;bar_color&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  reset&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;\033[0m&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  dim&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;\033[2;37m&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  while&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -lt&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$filled&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;color&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}\xe2\x96\x88${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;reset&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$((&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  while&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -lt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 10&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dim&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}\xe2\x96\x91${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;reset&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$((&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;  printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;[&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;]&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;\xe2\x96\x88&lt;/code&gt; is the full block character, &lt;code&gt;\xe2\x96\x91&lt;/code&gt; is the light shade. Escape color, character, reset, repeat. The trailing reset on every character matters. If you skip them, a tmux resize or scrollback can bleed color into adjacent text.&lt;/p&gt;
&lt;h2 id=&quot;the-reset-timestamp&quot;&gt;The reset timestamp&lt;/h2&gt;
&lt;p&gt;The nice detail I didn’t realize I wanted until I saw the finished version: the 5-hour bar shows when it resets, not just how full it is.&lt;/p&gt;
&lt;p&gt;The JSON gives you &lt;code&gt;resets_at&lt;/code&gt; as a Unix timestamp. On macOS, &lt;code&gt;date -r&lt;/code&gt; takes a timestamp directly:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;date&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_resets&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;+%-I:%M%p&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; 2&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  | &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;tr&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;[:upper:]&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;[:lower:]&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[ &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot; rst:${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;%-I&lt;/code&gt; strips the leading zero so &lt;code&gt;03:45pm&lt;/code&gt; renders as &lt;code&gt;3:45pm&lt;/code&gt;. &lt;code&gt;tr&lt;/code&gt; lowercases it so it reads closer to how I’d write the time in a note: &lt;code&gt;3:45pm&lt;/code&gt; rather than &lt;code&gt;3:45PM&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now the 5h segment looks like &lt;code&gt;5h:[████████░░]82% rst:3:45pm&lt;/code&gt;. I can see both the cost and the clock.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-surprised-me&quot;&gt;Where Claude surprised me&lt;/h2&gt;
&lt;p&gt;The severity coloring was me saying “make the bars not all gray, use any colors that make sense” and the agent came back with the green/yellow/red thresholds on its own. I was expecting I’d have to define the ranges. Not complicated logic, but it picked reasonable cutoffs without me specifying them and I didn’t need to adjust after.&lt;/p&gt;
&lt;h2 id=&quot;where-claude-fell-short&quot;&gt;Where Claude fell short&lt;/h2&gt;
&lt;p&gt;The first version wrote out full paths for the working directory. I told it I wanted just the folder name. That was a one-line &lt;code&gt;basename&lt;/code&gt; fix. Easy, but it’s the kind of thing “good default” might have caught without me asking.&lt;/p&gt;
&lt;p&gt;The first version also had ASCII bars all in the same dim gray. Functional but not useful. I had to explicitly ask for color differentiation. On re-read, my original prompt did ask for visual bars but didn’t say anything about severity, so that’s fair. Still, “make the bars informative” would have been my one-shot prompt if I were writing it now.&lt;/p&gt;
&lt;p&gt;It also wrote the code to extract &lt;code&gt;rate_limits.five_hour.resets_at&lt;/code&gt; before confirming the field actually existed in the JSON payload. It was a plausible path, and it happened to be correct, but it was a guess. For a less obvious field I’d have wasted a round on a hallucinated schema.&lt;/p&gt;
&lt;h2 id=&quot;the-full-claude-code-statusline-bash-script&quot;&gt;The full Claude Code statusline bash script&lt;/h2&gt;
&lt;p&gt;If you’d rather read the bash yourself, save this as &lt;code&gt;~/.claude/statusline-command.sh&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;#!/bin/sh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;input&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;cat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cwd&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$input&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;.cwd // .workspace.current_dir // empty&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ctx_used&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$input&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;.context_window.used_percentage // empty&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;five_pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$input&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;.rate_limits.five_hour.used_percentage // empty&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;five_resets&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$input&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;.rate_limits.five_hour.resets_at // empty&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;week_pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$input&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;.rate_limits.seven_day.used_percentage // empty&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;bar_color&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  int_pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;%.0f&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; 2&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; || &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$int_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -ge&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 81&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] 2&amp;gt;/dev/null; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;    printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;31m&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  elif&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$int_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -ge&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 50&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] 2&amp;gt;/dev/null; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;    printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;33m&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  else&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;    printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;32m&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;make_bar&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  pct&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  filled&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;%.0f&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; * 10 / 100&amp;quot; &lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; bc&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -l&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; 2&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null &lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; echo&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;)&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$filled&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -gt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 10&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;filled&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;10&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$filled&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -lt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;filled&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  color&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;bar_color&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  reset&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;\033[0m&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  dim&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;\033[2;37m&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  while&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -lt&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$filled&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;color&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}\xe2\x96\x88${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;reset&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$((&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  while&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -lt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 10&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dim&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}\xe2\x96\x91${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;reset&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$((&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;  printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;[&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;]&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$cwd&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  folder&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;basename&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$cwd&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  parts&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;36m%s\033[0m &amp;#39; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$folder&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;make_bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_resets&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;date&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_resets&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;+%-I:%M%p&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; 2&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      | &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;tr&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;[:upper:]&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;[:lower:]&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    [ &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ] &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot; rst:${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  parts&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;33m5h:\033[0m%s%.0f%%%s &amp;#39; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$five_reset_str&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$week_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;make_bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$week_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  parts&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;33m7d:\033[0m%s%.0f%% &amp;#39; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$week_pct&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$ctx_used&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bar&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;make_bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$ctx_used&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  parts&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parts&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;}$(&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;\033[0;35mctx:\033[0m%s%.0f%%&amp;#39; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$bar&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$ctx_used&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;printf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;%s&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;$parts&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make it executable (&lt;code&gt;chmod +x ~/.claude/statusline-command.sh&lt;/code&gt;), then add this to &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;statusLine&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;bash /Users/YOURNAME/.claude/statusline-command.sh&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace &lt;code&gt;YOURNAME&lt;/code&gt; with your actual home directory. Restart Claude Code. Run a prompt. The statusline shows up after the first response, which is when the JSON starts carrying real usage numbers.&lt;/p&gt;
&lt;h2 id=&quot;styling-tips&quot;&gt;Styling tips&lt;/h2&gt;
&lt;p&gt;A few things I learned by iterating on this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Label colors matter more than bar colors.&lt;/strong&gt; I used cyan for the cwd, yellow for 5h and 7d labels, purple for ctx. When I glance down, the label colors are what tell me which segment I’m looking at. The bar colors just tell me severity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keep segments narrow.&lt;/strong&gt; Ten-slot bars are small enough to fit four segments on one line in a typical terminal. I tried 20-slot bars at one point and they wrapped on my laptop screen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Always reset after every ANSI escape.&lt;/strong&gt; I’ve been burned by color bleeding into the rest of the terminal after a process crashes mid-write. The &lt;code&gt;${reset}&lt;/code&gt; after every character is paranoid but cheap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hide segments when the data isn’t there yet.&lt;/strong&gt; The rate-limit numbers aren’t populated until Claude Code has made at least one API call in the session. If you print them unconditionally you get an ugly &lt;code&gt;5h:[░░░░░░░░░░]&lt;/code&gt; on a fresh start. The &lt;code&gt;if [ -n ... ]&lt;/code&gt; guards handle that.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;Running on my machine. I see it every prompt. It has already saved me from one 5h wall I didn’t notice coming. Low stakes, nice feature, took about an hour of back-and-forth to land.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Tell Claude the severity thresholds up front.&lt;/strong&gt; I let the agent pick them and it happened to get it right, but on a different day I might have wanted different cutoffs and wasted a round correcting. Specifying “green under 50, yellow 50-80, red over 80” in the first prompt costs nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ask the agent to verify fields in the JSON before writing code.&lt;/strong&gt; The &lt;code&gt;resets_at&lt;/code&gt; field happened to exist. For a feature you’re actually shipping, the “does this field exist” check should happen before the “format it as a time” code. A one-line &lt;code&gt;jq&lt;/code&gt; against a sample payload would have caught it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don’t wait to try it.&lt;/strong&gt; I almost didn’t build this because it felt like yak-shaving. It took under an hour and changed how I pace work inside Claude Code. If you’ve never opened the &lt;code&gt;statusLine&lt;/code&gt; key in your &lt;code&gt;settings.json&lt;/code&gt;, that’s where to start.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/blog/&quot;&gt;more devlogs&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;side projects&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>claude-code</category><category>tutorial</category><category>bash</category><category>devlog</category><category>built-with-claude</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/statusline-cover.D-HeSZHw.png" length="0" type="image/jpeg"/></item><item><title>I Built a Dead Simple App Because Claude Code Couldn&apos;t Hear Me</title><link>https://travelvient.com/blog/building-mic-clipboard/</link><guid isPermaLink="true">https://travelvient.com/blog/building-mic-clipboard/</guid><description>Claude Code on Bedrock doesn&apos;t expose a microphone. I type slowly. So I built an iOS app that transcribes speech and drops it straight to the clipboard.</description><pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I use Claude Code through Bedrock at work. That version doesn’t give the AI access to your microphone, so the voice input that makes the native Claude desktop client fast just isn’t there. I like speaking to Claude more than typing. Over several months that small friction accumulated into something I actually wanted to fix.&lt;/p&gt;
&lt;p&gt;The fix was obvious: an app that listens, transcribes, and puts the text on the clipboard. Switch to whatever terminal or text field you’re using, paste. One step in the middle instead of typing everything out.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;Mic to Clipboard is one screen, one button. Tap the mic, speak, tap again. The transcript lands in your clipboard. You paste it wherever you want.&lt;/p&gt;
&lt;p&gt;That’s the whole app. No accounts, no sync, no settings beyond a light/dark mode toggle. It runs on-device: Apple’s speech recognizer does the transcription locally so nothing leaves your phone.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;React Native via Expo, because I wanted to ship to iOS without writing Swift. Two packages do all the real work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;expo-speech-recognition&lt;/code&gt; wraps Apple’s &lt;code&gt;SFSpeechRecognizer&lt;/code&gt; API&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expo-clipboard&lt;/code&gt; writes the final transcript to the system clipboard&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Expo’s managed workflow meant I could build the whole thing without opening Xcode during development. I only touched Xcode when it was time to configure things for the App Store submission.&lt;/p&gt;
&lt;h2 id=&quot;continuous-transcription&quot;&gt;Continuous transcription&lt;/h2&gt;
&lt;p&gt;The interesting part of the core hook is how continuous speech recognition actually works. Apple’s recognizer fires result events repeatedly as it processes audio. Each result is either interim (still processing, may change) or final (committed). But when you speak in long sentences with natural pauses, you get multiple final results in a row, not one big one at the end.&lt;/p&gt;
&lt;p&gt;So I keep a ref that accumulates the committed finals:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;useSpeechRecognitionEvent&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;result&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;event&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; text&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; event&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;results&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]?.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;transcript&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ??&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;event&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isFinal&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      accumulatedRef&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;current&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; accumulatedRef&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;current&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        ?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; accumulatedRef&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;current&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; text&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        :&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;      setState&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;prev&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        ...&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;prev&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;        transcript&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;accumulatedRef&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;current&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;        interimTranscript&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      }));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;    setState&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;prev&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      ...&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;prev&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      interimTranscript&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;accumulatedRef&lt;/code&gt; is a plain ref rather than state because I don’t want re-renders every time it updates mid-sentence. State updates only happen on final results. When the session ends, the accumulated string is what gets written to the clipboard.&lt;/p&gt;
&lt;p&gt;The display text that appears on screen combines both pieces: whatever is committed plus the in-flight interim, so you see words appearing as you speak:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  ...&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  toggle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  displayText&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;interimTranscript&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    ?&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;accumulatedRef&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;current&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        ?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; accumulatedRef&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;current&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;interimTranscript&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        :&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;interimTranscript&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    :&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;transcript&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;};&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;on-device-vs-network-fallback&quot;&gt;On-device vs. network fallback&lt;/h2&gt;
&lt;p&gt;Newer iPhones support fully on-device speech recognition. Older ones fall back to Apple’s servers. Rather than just picking one, the app checks at runtime and uses the right config:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; supportsOnDevice&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; ExpoSpeechRecognitionModule&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;supportsOnDeviceRecognition&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; config&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; supportsOnDevice&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; SPEECH_CONFIG&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; SPEECH_CONFIG_NETWORK&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ExpoSpeechRecognitionModule&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;start&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;config&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two configs are identical except for &lt;code&gt;requiresOnDeviceRecognition: true&lt;/code&gt;. On-device is preferred because nothing leaves the device, but requiring it on older hardware would just fail silently. The fallback handles it without any user-visible difference.&lt;/p&gt;
&lt;h2 id=&quot;silencing-the-no-speech-error&quot;&gt;Silencing the &lt;code&gt;no-speech&lt;/code&gt; error&lt;/h2&gt;
&lt;p&gt;If you tap the mic button and then don’t say anything, the recognizer fires an error event with code &lt;code&gt;no-speech&lt;/code&gt;. I was initially treating that the same as real errors, which meant the UI would flash an error state every time someone changed their mind or accidentally tapped the button.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;useSpeechRecognitionEvent&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;error&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;event&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;event&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;error&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;no-speech&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // handle actual errors&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Silence isn’t an error. Filtering it out means the button just returns to idle with no drama.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-app-store-paperwork&quot;&gt;The hard part: App Store paperwork&lt;/h2&gt;
&lt;p&gt;The code took a weekend. Getting through App Store review took longer and was more tedious than I expected.&lt;/p&gt;
&lt;p&gt;Apple’s privacy manifest system requires a structured XML declaration of which system APIs you use and why. &lt;code&gt;expo-speech-recognition&lt;/code&gt; accesses the microphone, and apps using certain APIs need to explain themselves in a format Apple can parse. The permission strings in &lt;code&gt;Info.plist&lt;/code&gt; also needed to be specific enough to pass review.&lt;/p&gt;
&lt;p&gt;There was also the encryption declaration. Any app that uses HTTPS, even passively (every app does), technically uses encryption and needs to be flagged as non-exempt. It’s a paperwork step, not a security review, but a missing checkbox gets the submission bounced.&lt;/p&gt;
&lt;p&gt;Screenshot requirements were the most mechanical part: specific pixel dimensions for iPhone 6.7” and 6.5” layouts, taken from simulators at exactly those resolutions. Three to five screens minimum. It’s a twenty-minute process once you know the sizes. Discovering them for the first time mid-submission is not ideal.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;Live on the &lt;a href=&quot;https://apps.apple.com/us/app/mic-to-clipboard/id6761743749&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;App Store&lt;/a&gt;. Works on iPhone and iPad. Apple automatically makes it available on Apple Silicon Macs via the “Designed for iPad” compatibility layer, which means zero extra work on my end.&lt;/p&gt;
&lt;p&gt;I use it every day. I’ll draft a long Claude prompt on the walk to my desk, open the app, say it, and paste it into the terminal. Fast enough that it doesn’t break the flow.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Skip Mac Catalyst configuration.&lt;/strong&gt; The automatic Mac compatibility through “Designed for iPad” covers everything I wanted. I spent time setting up Catalyst entitlements, sandbox configs, and Xcode targets that turned out to be unnecessary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Take screenshots during development.&lt;/strong&gt; I treated them as a final step and got stuck mid-submission setting up a simulator at the right resolution. They could have been done any time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Budget a full day for App Store paperwork.&lt;/strong&gt; The code was done in two days. Getting the privacy manifest, encryption declaration, permission strings, screenshots, and privacy policy all correct and in place took another full day. It’s not hard, just time-consuming, and you can’t skip it.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/mic-clipboard/&quot;&gt;Mic Clipboard&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;more side projects&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>react-native</category><category>expo</category><category>ios</category><category>speech-recognition</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/mic-clipboard.C8wvKF2p.webp" length="0" type="image/jpeg"/></item><item><title>Building the Carry-On and Personal Item Size Checkers</title><link>https://travelvient.com/blog/building-airline-size-checkers/</link><guid isPermaLink="true">https://travelvient.com/blog/building-airline-size-checkers/</guid><description>Two free tools that tell you whether your bag fits a given airline, built on a cited dataset of 50 carriers sourced from official policy pages.</description><pubDate>Tue, 14 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Editor’s note (May 2026):&lt;/strong&gt; This devlog predates Spirit Airlines’ May 2, 2026 shutdown. The Spirit size-checker page still exists as a historical reference, but Spirit no longer operates.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Last summer I watched a gate agent at Stansted pull a woman’s backpack out of her hand, drop it into a Ryanair sizer, and charge her €55 because it was half an inch tall in one dimension. The bag was “carry-on approved” according to the Amazon listing. She had measured it at home. The problem was that she had measured it against American Airlines limits, which is what every US-facing review compared against, and Ryanair is a different airline with different rules.&lt;/p&gt;
&lt;p&gt;I went home and started a spreadsheet. A few weeks later the spreadsheet became a JSON file. A few weeks after that, it became two tools: the &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/&quot;&gt;Carry-On Size Checker&lt;/a&gt; and the &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/&quot;&gt;Personal Item Size Checker&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-they-do&quot;&gt;What they do&lt;/h2&gt;
&lt;p&gt;Both tools take the same input (length, width, height, in inches or centimeters) and compare your bag against the published baggage rules for 50 airlines worldwide. You see, at a glance, which airlines will accept the bag as a carry-on or as a personal item and which will send you to the fee counter.&lt;/p&gt;
&lt;p&gt;You can filter by region (North America, Europe, Asia, etc.) or by category (Full-service, Low-cost, Ultra-low-cost) since what passes on Delta is not the same as what passes on Spirit. Every airline card also links to a detail page with the full baggage policy: checked bag fees, overweight surcharges, basic economy restrictions, gate-check risk, and the source URL for the data.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;This one is boring on purpose. Static Astro 5 site, Tailwind v4, one big JSON file, a few TypeScript helpers, and vanilla JavaScript in the browser for filtering. No React, no API, no database, no server.&lt;/p&gt;
&lt;p&gt;Every page is generated at build time. The bag fit check runs entirely client-side against data attributes baked into each airline card during the build. The whole thing deploys to Cloudflare Pages as static HTML and runs faster than any SaaS version of the same tool I tried.&lt;/p&gt;
&lt;p&gt;The data lives in &lt;code&gt;src/data/airlines/airlines.json&lt;/code&gt;, which is a 4,000-line structured file compiled from each airline’s official policy page and cross-referenced against secondary travel sources before publishing. Every airline entry includes carry-on dimensions in both inches and centimeters, personal item rules, checked bag fee structure, basic economy restrictions, and a &lt;code&gt;sourceUrl&lt;/code&gt; pointing at the airline’s own published policy page. Every entry also has a &lt;code&gt;lastVerified&lt;/code&gt; date. When I update a record I update the date. If the date is stale, I know to re-check.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-bag-orientation&quot;&gt;The hard part: bag orientation&lt;/h2&gt;
&lt;p&gt;This is the detail that almost everyone building a similar tool gets wrong. Airline dimensions are published as length × width × height, but bags are not. A 22×14×9 carry-on measured one way is a 9×22×14 bag measured another way. If you compare them position-by-position you will tell someone their bag does not fit when it does.&lt;/p&gt;
&lt;p&gt;The fix is to sort both the bag and the limit largest-to-smallest before comparing:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; bagFitsPersonalItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  bag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Dimensions&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  unit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;in&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;cm&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  airline&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Airline&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;PersonalItemFit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; limit&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; unit&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;in&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; airline&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;personalItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dimensionsIn&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; airline&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;personalItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dimensionsCm&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;airline&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;personalItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;allowed&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;too-big&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;limit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;under-seat-only&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;width&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;].&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;sort&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; l&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;limit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;limit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;width&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;limit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;].&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;sort&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; l&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; l&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; l&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;fits&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;too-big&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This also runs in the browser for the interactive filter. Same logic, data attributes on each card, no hydration framework needed:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; bagSorted&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bagCheck&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;l&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bagCheck&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;w&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bagCheck&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;h&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;].&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;sort&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; limSorted&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;limL&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;limW&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;limH&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;].&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;sort&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; a&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; ok&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bagSorted&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; limSorted&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bagSorted&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; limSorted&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  bagSorted&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; limSorted&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is an embarrassingly small amount of code for how much it matters. But it is the difference between a tool that gives you the right answer and a tool that misses a third of passing bags because the user entered them in a different order.&lt;/p&gt;
&lt;h2 id=&quot;the-not-published-problem&quot;&gt;The “not published” problem&lt;/h2&gt;
&lt;p&gt;The other thing that bit me is that half of US airlines do not publish personal item dimensions. Delta, American, and United all say something like “must fit under the seat in front of you” and leave it at that. If I treat that as “no limit,” I am lying to users. If I treat it as “does not allow personal items,” I am also lying.&lt;/p&gt;
&lt;p&gt;The schema has a three-state return for this reason:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; type&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; PersonalItemFit&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;fits&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;too-big&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;under-seat-only&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the data model lets a personal item be &lt;code&gt;allowed: true&lt;/code&gt; with null dimensions:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;personalItem&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;allowed&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;dimensionsIn&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;dimensionsCm&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;notes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Must fit under the seat in front of you. No published dimensions, but purses, small backpacks, and laptop bags are accepted.&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The UI shows an amber “Under-seat only” badge for these cases and links the user to the airline’s own policy page. It is not a satisfying answer, but it is an honest one, and it is better than pretending the airline has clear rules when it does not.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;The original version of the tool supported 20 airlines and had a single dimensions schema. I thought that would be enough. It was not. Within a week of launching I had feedback asking about Wizz Air, TAP Portugal, LATAM, Qantas, and half a dozen Asian carriers. Each one had slightly different rules. TAP uses weight differently. Wizz Air has a “Priority” upgrade that changes the personal item size. Qantas varies by route. The schema grew to cover all of it, but the JSON file tripled in size and every addition meant another trip to the airline’s policy page to re-verify numbers I had already verified two months earlier.&lt;/p&gt;
&lt;p&gt;The other thing that went wrong: I underestimated how often airlines change their rules. Delta changed checked bag fees from $35 to $45 halfway through the project. Spirit restructured personal item allowances. The data is only as good as the last verification date, and keeping 50 airlines current is more work than I budgeted for.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The tools have 50 airlines covered, grouped into 5 regions and 3 categories, with full checked bag fee estimation for US airlines. Each tool has its own page, each airline has its own detail page at &lt;code&gt;/tools/carry-on-size/[slug]/&lt;/code&gt;, and the whole thing is indexed in the Astro sitemap. Every page ships JSON-LD structured data (FAQPage, CollectionPage, BreadcrumbList, ItemList) so Google and AI search engines can pull answers out of it.&lt;/p&gt;
&lt;p&gt;Traffic has been better than expected. The personal item checker in particular gets picked up by “will my bag fit on Ryanair” and “Spirit personal item size” searches, which turn out to be very high-intent queries. People who are about to pay a $75 bag fee are motivated to click.&lt;/p&gt;
&lt;p&gt;If you want to see the per-airline detail pages in action, the big US carriers are a good sample: &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/jetblue/&quot;&gt;JetBlue carry-on size&lt;/a&gt;, &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/delta-air-lines/&quot;&gt;Delta carry-on size&lt;/a&gt;, &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/united-airlines/&quot;&gt;United carry-on size&lt;/a&gt;, &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/american-airlines/&quot;&gt;American Airlines carry-on size&lt;/a&gt;, and &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/southwest-airlines/&quot;&gt;Southwest carry-on size&lt;/a&gt;. The budget carriers tell a more interesting story: &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/frontier-airlines/&quot;&gt;Frontier&lt;/a&gt;, &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/ryanair/&quot;&gt;Ryanair&lt;/a&gt;, and &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/wizz-air/&quot;&gt;Wizz Air&lt;/a&gt; all publish tighter limits and charge more when you miss them, which is exactly the scenario the tool was built to prevent. &lt;a href=&quot;https://travelvient.com/tools/carry-on-size/spirit-airlines/&quot;&gt;Spirit&lt;/a&gt; was the strictest US example until it shut down in May 2026; its page is kept for reference.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;First, I would start with the full schema. The rewrite from a 3-field dimension object to a 20-field airline spec was avoidable. If I had spent an extra hour at the start looking at five airline policies side by side, I would have seen the variance and designed for it.&lt;/p&gt;
&lt;p&gt;Second, I would build the verification workflow before the data file got big. Right now updating an airline means finding it in 4,000 lines of JSON, editing, updating &lt;code&gt;lastVerified&lt;/code&gt;, and committing. A simple CLI that prompts me through the fields for one airline, validates against a schema, and writes the JSON would have saved hours. It is on the list.&lt;/p&gt;
&lt;p&gt;Third, I would not have built two separate tools at first. Carry-on and personal item share almost all their logic. The split made sense for SEO because people search for them as distinct things, but the shared code is now duplicated across two index pages and two detail templates. A single generic “bag fit” tool with two SEO landing pages pointing at it would have been cleaner.&lt;/p&gt;
&lt;p&gt;Still, both tools work, both are free, and nobody has emailed me to say I gave them wrong dimensions. For a side project built from a grudge at a Ryanair gate, I will take it.&lt;/p&gt;</content:encoded><category>astro</category><category>typescript</category><category>travel-tools</category><category>seo</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Built a Media Kit Generator Because a Creator Asked Me To</title><link>https://travelvient.com/blog/building-creatorkit/</link><guid isPermaLink="true">https://travelvient.com/blog/building-creatorkit/</guid><description>A content creator needed a media kit PDF. Every tool I found wanted a monthly subscription. So I built one that&apos;s free, no signup, and generates a polished.</description><pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (May 2026): CreatorKit has been shelved.&lt;/strong&gt; Analytics showed almost no usage, and the tool drifts pretty far from the travel focus of the rest of the site. The build story below is left up as a devlog. The hosted app may or may not still be running.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A creator I know needed a media kit to send to brands. The kind of one-pager with your stats, your audience, your collab rates. Every tool she found either wanted $15/month or required signing up and handing over her social accounts.&lt;/p&gt;
&lt;p&gt;I said I could probably build one over a weekend. That turned out to be half true.&lt;/p&gt;
&lt;h2 id=&quot;what-creatorkit-actually-does&quot;&gt;What CreatorKit actually does&lt;/h2&gt;
&lt;p&gt;You fill out a six-step form: basic info, platform stats, audience demographics, content highlights, collaboration types, and template/color preferences. Then you hit download and get an A4 PDF that looks like something a designer made.&lt;/p&gt;
&lt;p&gt;No signup. No account. No monthly fee. You get five downloads per day and that’s it.&lt;/p&gt;
&lt;p&gt;The YouTube integration is the one clever bit. Paste your &lt;code&gt;@handle&lt;/code&gt; and it auto-fetches your subscriber count, total views, and video count through the YouTube Data API. Everything else is manual entry, which honestly is fine for something you update once a quarter.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; on &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; via OpenNext. I went with Next because the Travel Vient ecosystem already runs on it, and Cloudflare because KV namespaces give me rate limiting and YouTube API caching for free (well, pennies).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;jsPDF&lt;/strong&gt; for client-side PDF generation. This was the big decision. I could have used Puppeteer on a server to render HTML to PDF, but that means spinning up a headless browser per request. Client-side means zero server cost for the actual PDF rendering. The user’s browser does all the work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tailwind v4&lt;/strong&gt; for styling, &lt;strong&gt;Zod&lt;/strong&gt; for validation schemas (more on that later), and &lt;strong&gt;Cloudflare KV&lt;/strong&gt; for rate limiting with daily resets.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-drawing-a-pdf-by-hand&quot;&gt;The hard part: drawing a PDF by hand&lt;/h2&gt;
&lt;p&gt;jsPDF gives you a blank canvas and a coordinate system measured in millimeters. That’s it. No flexbox. No grid. No &lt;code&gt;text-align: center&lt;/code&gt; that actually works how you’d expect. You’re calling &lt;code&gt;doc.text()&lt;/code&gt; with exact x/y coordinates and hoping things line up.&lt;/p&gt;
&lt;p&gt;Every element on the page needs a helper function that returns the Y position after it renders, so the next element knows where to start:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; drawText&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  doc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;jsPDF&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  options&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;DrawTextOptions&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;fontSize&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 10&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;fontStyle&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;normal&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;color&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;#000000&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;align&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;left&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;maxWidth&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; options&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;r&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; hexToRgb&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;color&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  doc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setTextColor&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;r&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  doc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setFontSize&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;fontSize&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  doc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setFont&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;helvetica&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;fontStyle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;maxWidth&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; lines&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; doc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;splitTextToSize&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;maxWidth&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    doc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lines&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;align&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; y&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; lines&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; fontSize&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0.4&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  doc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;align&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; y&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; fontSize&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0.4&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;fontSize * 0.4&lt;/code&gt; magic number? That’s the approximate line height in millimeters. I arrived at it through trial and error, printing PDFs and holding them up to a ruler. There is no &lt;code&gt;getLineHeight()&lt;/code&gt; in jsPDF. You just guess until it looks right.&lt;/p&gt;
&lt;p&gt;The platform stats section was especially painful because it needs to handle 1 to 6+ platforms in a responsive grid:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; colCount&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;platforms&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; colWidth&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; CONTENT_WIDTH&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; colCount&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; boxHeight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 28&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;platforms&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;forEach&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;platform&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; col&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; %&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; colCount&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; row&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;floor&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; colCount&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; bx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; MARGIN&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; col&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; colWidth&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; by&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; y&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; row&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;boxHeight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 3&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // ... draw each stat box&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three columns max, overflow to new rows. It’s the kind of layout that CSS grid handles in one line. In jsPDF, you’re computing column offsets and row indices by hand.&lt;/p&gt;
&lt;h2 id=&quot;images-had-their-own-problems&quot;&gt;Images had their own problems&lt;/h2&gt;
&lt;p&gt;Every image the user uploads gets resized client-side before it touches the PDF. I used &lt;code&gt;OffscreenCanvas&lt;/code&gt; to keep the main thread clear, then compress to JPEG at 85% quality:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; async&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; processImageFile&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;file&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;File&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ImageProcessResult&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; bitmap&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; createImageBitmap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;file&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;width&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; getScaledDimensions&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bitmap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;width&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;bitmap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;MAX_DIMENSION&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; canvas&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; OffscreenCanvas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;width&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; ctx&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; canvas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getContext&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;2d&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;throw&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Error&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Failed to get canvas context&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  ctx&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;drawImage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bitmap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;width&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  bitmap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;close&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; blob&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; canvas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;convertToBlob&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;({ &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;type&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;image/jpeg&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;quality&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.85&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; base64&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; blobToBase64&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;blob&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;base64&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;width&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;height&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without this step, a 4MB profile photo would bloat the PDF to unusable sizes. Capping at 400x400 and 85% JPEG keeps the final PDF under a megabyte in most cases.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;I planned three templates: minimal, bold, and pastel. I finished one.&lt;/p&gt;
&lt;p&gt;The minimal template is about 290 lines of coordinate math. Every section (header, bio, stats, audience, highlights, collaboration types, partners, contact footer) needs its own layout logic. And every template needs to reimplement all of that with different spacing, colors, backgrounds, and typography.&lt;/p&gt;
&lt;p&gt;The bold template has a dark background, which means every text color needs to flip. The pastel template uses rounded containers everywhere, which means more coordinate math for border radii. I got about 60% through bold before I realized I was going to spend more time on templates than I had on the entire rest of the app.&lt;/p&gt;
&lt;p&gt;So right now, only minimal is unlocked. Bold and pastel are visible in the template picker but greyed out.&lt;/p&gt;
&lt;p&gt;The other thing that went sideways: I built full Zod validation schemas for the form data and then never wired them into the UI. The schemas exist in &lt;code&gt;validation.ts&lt;/code&gt;, fully typed, fully correct, completely unused. The form just dispatches raw field updates through a reducer. It works fine, but there’s zero client-side validation beyond “is this field empty.”&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;Live at &lt;a href=&quot;https://travelvient.com/tools/media-kit&quot;&gt;travelvient.com/tools/media-kit&lt;/a&gt;. The YouTube auto-fetch works well. The minimal template produces a genuinely nice-looking PDF. Rate limiting keeps costs near zero, since the YouTube API calls get cached in KV for 24 hours and each IP gets 10 lookups per day.&lt;/p&gt;
&lt;p&gt;The live preview panel updates in real-time as you fill out the form, scaled to 47% of A4 size. It’s built with React components that mirror the PDF layout, which is its own maintenance headache since any PDF change needs a matching preview change.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Talk to more creators first.&lt;/strong&gt; I built this because one person asked for it. I should have talked to ten more before writing a line of code. Does the six-step form collect too much? Too little? Are the collaboration type categories even right? I don’t actually know if this tool solves a real pain point at scale, or just for one person.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use HTML-to-PDF instead of jsPDF.&lt;/strong&gt; Something like Puppeteer or Playwright rendering a styled HTML page would let me write templates in CSS instead of coordinate math. Yes, it needs a server. But the developer experience of &lt;code&gt;drawText(doc, text, 43.5, 127, { fontSize: 7 })&lt;/code&gt; is genuinely bad, and it would have saved days on the template work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ship one template and move on faster.&lt;/strong&gt; I spent time building the template picker UI, the color customization, the locked template states, all before I had even one template fully working. Should have shipped minimal alone, without the template system, and added the abstraction only when template #2 was actually ready.&lt;/p&gt;
&lt;p&gt;Related: see &lt;a href=&quot;https://travelvient.com/projects/creatorkit/&quot;&gt;CreatorKit&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>next-js</category><category>jspdf</category><category>cloudflare-workers</category><category>typescript</category><category>pdf-generation</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Built an API Cost Tracker and Then Couldn&apos;t Ship It</title><link>https://travelvient.com/blog/shelving-burnrate/</link><guid isPermaLink="true">https://travelvient.com/blog/shelving-burnrate/</guid><description>BurnRate was supposed to be a unified dashboard for Anthropic and OpenAI spend. It worked great, until I realized the keys it needed could do a lot more than.</description><pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I opened my Anthropic dashboard one morning and genuinely flinched. I’d been building with Claude Code pretty heavily that week and my bill was way higher than expected. Not catastrophic, but enough to make me think: why am I always surprised by this number?&lt;/p&gt;
&lt;p&gt;The problem got worse when I started using OpenAI’s APIs too. Now I had two dashboards, two billing pages, two sets of numbers I needed to mentally combine to understand what I was actually spending. So I did what any developer does when mildly annoyed. I built something.&lt;/p&gt;
&lt;h2 id=&quot;what-burnrate-does&quot;&gt;What BurnRate Does&lt;/h2&gt;
&lt;p&gt;BurnRate is a unified API cost dashboard. You paste in your Anthropic and OpenAI admin keys, and it gives you one view of everything: daily spend trends, per-model breakdowns (how much went to Opus vs. Sonnet, GPT-4o vs. o3), projected monthly costs based on your actual burn rate, and budget warnings when you’re getting close to a limit you set.&lt;/p&gt;
&lt;p&gt;The whole thing runs on Next.js 16 with Cloudflare Workers, and the core design principle was “zero persistence.” No database, no accounts, no server-side key storage. Keys live in your browser and get sent to edge functions only when fetching data.&lt;/p&gt;
&lt;h2 id=&quot;the-provider-pattern&quot;&gt;The Provider Pattern&lt;/h2&gt;
&lt;p&gt;The most satisfying part of the architecture was the provider abstraction. Anthropic and OpenAI have completely different billing APIs, different pagination schemes, different data shapes. I needed a clean way to normalize all of that.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; interface&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; UsageProvider&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  id&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;anthropic&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;openai&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  keyPrefix&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  validateKey&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;key&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;{ &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;valid&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;boolean&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;error&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }&amp;gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  fetchUsage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;key&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;startDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;endDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ProviderUsage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each provider implements this interface, and the rest of the app doesn’t care which one it’s talking to. Adding a new provider (Google AI, Mistral, whatever) would just mean creating a new file and registering it. The dashboard code stays untouched.&lt;/p&gt;
&lt;h2 id=&quot;dealing-with-two-very-different-apis&quot;&gt;Dealing with Two Very Different APIs&lt;/h2&gt;
&lt;p&gt;Anthropic uses cursor-based pagination with a &lt;code&gt;next_page&lt;/code&gt; token. OpenAI uses page numbers. Their cost data comes back in different formats, different field names, different granularities. I wrote a generic pagination helper to smooth this over:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fetchAllPages&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  baseUrl&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  params&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;URLSearchParams&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  headers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;HeadersInit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  extractData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;json&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[]; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;has_more&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;boolean&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;next_page&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[]&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; allData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; page&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  do&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; urlParams&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; URLSearchParams&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;params&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;page&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;urlParams&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;set&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;page&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;page&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; res&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fetch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;baseUrl&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;urlParams&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toString&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;headers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;res&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ok&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;throw&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Error&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`API error (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;res&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; res&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; json&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; res&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;json&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    allData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(...&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;extractData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;json&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    page&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; json&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;has_more&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; json&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;next_page&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;while&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;page&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; allData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then both providers fetch their cost and usage reports in parallel with &lt;code&gt;Promise.all&lt;/code&gt;, which cuts the latency roughly in half since we’re hitting two endpoints per provider.&lt;/p&gt;
&lt;h2 id=&quot;the-key-security-problem&quot;&gt;The Key Security Problem&lt;/h2&gt;
&lt;p&gt;This is where the whole thing fell apart. And honestly, it’s not a bug or a technical limitation. It’s a fundamental design problem.&lt;/p&gt;
&lt;p&gt;Both Anthropic and OpenAI require &lt;strong&gt;admin-level API keys&lt;/strong&gt; to access billing and usage data. These aren’t read-only keys. They’re the same keys that can create API requests, manage organization settings, and do basically anything on your account.&lt;/p&gt;
&lt;p&gt;I designed BurnRate to be “zero trust” from the start. Keys stored in &lt;code&gt;sessionStorage&lt;/code&gt; by default, cleared when you close the tab. Optional &lt;code&gt;localStorage&lt;/code&gt; if you check “Remember me.” Keys never persisted server-side.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; saveKeys&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; useCallback&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;newKeys&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ProviderKeys&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;shouldRemember&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;boolean&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;    // Clear from both storages first&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    localStorage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;removeItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;STORAGE_KEY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    sessionStorage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;removeItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;STORAGE_KEY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;    // Save to the appropriate storage&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; storage&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; shouldRemember&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; localStorage&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; sessionStorage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    storage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;STORAGE_KEY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;JSON&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;stringify&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;newKeys&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    localStorage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;REMEMBER_KEY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;shouldRemember&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toString&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;());&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But here’s the thing. Even with all that care, the keys still have to travel through my edge function to reach the provider APIs. That means they pass through infrastructure I control. And even though I’d never log them or store them, users have no way to verify that. I’m asking people to trust me with keys that have full admin access to their AI accounts.&lt;/p&gt;
&lt;p&gt;I couldn’t ship that in good conscience.&lt;/p&gt;
&lt;h2 id=&quot;what-the-apis-didnt-give-me&quot;&gt;What the APIs Didn’t Give Me&lt;/h2&gt;
&lt;p&gt;Beyond the key problem, the provider APIs had their own limitations. Neither Anthropic nor OpenAI gives you everything you’d want for a proper cost dashboard out of the box. Some data is bucketed in ways that make fine-grained analysis tricky. Token counts and cost amounts come from separate endpoints, so you have to merge them yourself and hope the model names match up:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; mergeDashboardData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;providers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ProviderUsage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[]): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;DashboardData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; totalCost&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; providers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;reduce&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;sum&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; sum&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;totalCost&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; dailyMap&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; provider&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; of&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; providers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; dc&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; of&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; provider&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dailyCosts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      dailyMap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;set&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;dc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;dailyMap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;dc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; dc&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cost&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // Burn rate only counts days with actual usage&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; daysWithData&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; dailyCosts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;filter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; d&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cost&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; burnRate&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; daysWithData&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; totalCost&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; daysWithData&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; :&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; projectedMonthlyCost&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; burnRate&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 30&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;providers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;totalCost&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;dailyCosts&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;burnRate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;projectedMonthlyCost&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;modelBreakdown&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The burn rate calculation was a small decision that mattered. If you only use the API on weekdays, averaging across all 30 days would undercount your actual daily spend. So I only count days where there’s real usage, then project from there. It’s a small thing, but it’s the difference between a number you trust and one you don’t.&lt;/p&gt;
&lt;h2 id=&quot;graceful-degradation&quot;&gt;Graceful Degradation&lt;/h2&gt;
&lt;p&gt;One pattern I’m glad I built in: partial failure handling. If your Anthropic key works but your OpenAI key is expired, you still see your Anthropic data. The dashboard renders what it can and shows a warning for what failed.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; results&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;allSettled&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;fetches&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;results&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;forEach&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;result&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;index&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;result&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;fulfilled&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    providers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;result&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    errors&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      provider&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;providerName&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      error&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;result&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;reason&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;?.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;Unknown error&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Promise.allSettled&lt;/code&gt; instead of &lt;code&gt;Promise.all&lt;/code&gt; was the key choice here. One bad key shouldn’t nuke the whole dashboard.&lt;/p&gt;
&lt;h2 id=&quot;where-it-stands-now&quot;&gt;Where It Stands Now&lt;/h2&gt;
&lt;p&gt;BurnRate is on the back burner. The code works. The dashboard looks good. The architecture is clean. But I can’t ask people to hand over admin keys to a web app, and the providers don’t offer read-only billing scopes that would make this safe.&lt;/p&gt;
&lt;p&gt;If Anthropic or OpenAI ever ship scoped API keys with read-only billing access, I’ll dust this off in a heartbeat. The provider pattern means I could add that support in an afternoon.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d Do Differently&lt;/h2&gt;
&lt;p&gt;The honest answer is that I’d build a real backend with proper encrypted key storage, server-side sessions, and all the infrastructure that comes with handling sensitive credentials. I avoided that because I wanted to keep things simple and minimize risk. No database, no encryption keys to manage, no breach surface.&lt;/p&gt;
&lt;p&gt;But it turns out that’s the only real way to accomplish what BurnRate is trying to do. You can’t build a tool that needs admin API keys and also avoid the responsibility of securing them. The “keys stay in your browser” approach felt clever, but the keys still transit through server infrastructure on every request. The risk doesn’t disappear just because you don’t persist it to disk.&lt;/p&gt;
&lt;p&gt;For now, I don’t want to take on that responsibility for other people’s keys. Maybe that changes. Maybe the providers give us better scoping. Either way, BurnRate taught me that sometimes the hardest part of shipping isn’t the code. It’s deciding whether you should.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;side projects&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/&quot;&gt;more devlogs&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>next-js</category><category>typescript</category><category>cloudflare-workers</category><category>react</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Built an SEO Scanner That Actually Checks if AI Can Find You</title><link>https://travelvient.com/blog/building-shipready/</link><guid isPermaLink="true">https://travelvient.com/blog/building-shipready/</guid><description>Lighthouse doesn&apos;t check if ChatGPT can crawl your site. So I built ShipReady, an SEO and AEO scanner that audits your site for the age of answer engines.</description><pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I kept running Lighthouse on my sites and getting green scores across the board. Great. But then I’d ask ChatGPT about one of my projects and get nothing. Perplexity didn’t know it existed. My SEO was fine. My AEO was invisible.&lt;/p&gt;
&lt;p&gt;Lighthouse is a great tool, but it was built for a world where Google was the only game in town. It doesn’t check if your robots.txt blocks GPTBot. It doesn’t care about llms.txt. It has no concept of whether your content is structured for answer engines to extract and cite.&lt;/p&gt;
&lt;p&gt;So I built &lt;a href=&quot;https://travelvient.com/tools/seo-check/&quot;&gt;ShipReady&lt;/a&gt;, a scanner that audits your site across seven categories, scores them with letter grades, and generates a “fix all” prompt you can paste directly into Claude or Cursor.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What It Does&lt;/h2&gt;
&lt;p&gt;You give it a URL. It fetches the page, follows redirects, grabs robots.txt and sitemap.xml and llms.txt in parallel, then runs 35+ checks across seven weighted categories:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Meta &amp;amp; Head&lt;/strong&gt; (23%) - title, description, charset, viewport, canonical&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discoverability&lt;/strong&gt; (19%) - HTTPS, robots.txt, sitemap, noindex detection&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On-Page Structure&lt;/strong&gt; (19%) - heading hierarchy, alt text, internal links&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Social Sharing&lt;/strong&gt; (14%) - Open Graph, Twitter Cards&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance&lt;/strong&gt; (9%) - render-blocking scripts, compression, modern image formats&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured Data&lt;/strong&gt; (8%) - JSON-LD validation, schema type suggestions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Answer Engine Optimization&lt;/strong&gt; (8%) - AI crawler access, llms.txt, question headings, speakable schema&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The weighting is opinionated. Meta and head matter more than performance for discoverability. AEO is weighted lower because it’s newer, but it’s the category most tools completely ignore.&lt;/p&gt;
&lt;p&gt;The killer feature is the fix prompt. Every failed check generates a code snippet, and ShipReady combines them into a single markdown prompt with critical issues, warnings, and guidelines. You copy it, paste it into your AI coding tool, and the fixes get applied.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The Stack&lt;/h2&gt;
&lt;p&gt;Astro 5 with a Cloudflare Workers adapter. The whole thing runs on the edge. No origin server, no cold starts worth worrying about, and the rate limiting uses Cloudflare KV with a one-hour TTL.&lt;/p&gt;
&lt;p&gt;The interesting architectural choice was going fully server-side for the scanning. The API endpoint receives a URL, does all the fetching and parsing on the Worker, and returns a scored JSON result. The frontend is just a form and a renderer.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;mainResponse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;externalData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;all&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;([&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  fetchFollowingRedirects&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;targetUrl&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  fetchExternalResources&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;validation&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;url&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; parsedData&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; parseHTML&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;mainResponse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;finalUrl&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; categories&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; runAllChecks&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;parsedData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;externalData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;categories&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; scoreCategories&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;categories&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Everything that can run in parallel does. The main page fetch and external resource fetch (robots.txt, sitemap, llms.txt) happen concurrently. The external resources use &lt;code&gt;Promise.allSettled&lt;/code&gt; so a missing sitemap doesn’t tank the whole scan.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-parsing-html-without-a-dom&quot;&gt;The Hard Part: Parsing HTML Without a DOM&lt;/h2&gt;
&lt;p&gt;Cloudflare Workers have tight memory constraints. I couldn’t pull in jsdom or cheerio. So the entire HTML parser is regex-based. Every meta tag, heading, image, script, and anchor gets extracted with handwritten regex patterns.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; getAttr&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;tag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;attr&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; re&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; RegExp&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;attr&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;s*=&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;s*(?:&amp;quot;([^&amp;quot;]*)&amp;quot;|&amp;#39;([^&amp;#39;]*)&amp;#39;|([^&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;s&amp;gt;]+))`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;i&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; m&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; tag&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;match&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;re&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;m&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; decodeHtmlEntities&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;m&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;??&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; m&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;??&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; m&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This little function handles double-quoted, single-quoted, and unquoted attribute values, then decodes HTML entities. It’s called hundreds of times per scan. Every tag type gets its own regex loop over the HTML string, and the parser splits head from body so script detection can tell whether a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag is render-blocking or not.&lt;/p&gt;
&lt;p&gt;It works. It’s fast. But writing regex to parse HTML is one of those things where you know you’re technically doing it wrong and you do it anyway because the constraints demand it.&lt;/p&gt;
&lt;h2 id=&quot;the-false-positives&quot;&gt;The False Positives&lt;/h2&gt;
&lt;p&gt;The first version flagged basically every Cloudflare-hosted site for missing text compression. That was fun.&lt;/p&gt;
&lt;p&gt;The problem: Cloudflare Workers auto-decompress fetch responses. When ShipReady fetched a page, the &lt;code&gt;content-encoding&lt;/code&gt; header was already stripped. The server was compressing, but the scanner couldn’t see it.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; contentEncoding&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;responseHeaders&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;content-encoding&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; vary&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;responseHeaders&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;vary&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; hasCompression&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;contentEncoding&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;contentEncoding&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;includes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;gzip&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; contentEncoding&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;includes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;br&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;))) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;||&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  vary&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toLowerCase&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;includes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;accept-encoding&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fix was checking the &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt; header as a secondary signal. If the server varies its response based on encoding, it’s compressing. Not perfect, but it eliminated the false positives.&lt;/p&gt;
&lt;p&gt;There was another one with &lt;code&gt;type=&amp;quot;module&amp;quot;&lt;/code&gt; scripts. The original code flagged any script in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; without &lt;code&gt;async&lt;/code&gt; or &lt;code&gt;defer&lt;/code&gt; as render-blocking. But ES modules are deferred by spec. Every modern Astro and Vite site ships module scripts, so every modern site was getting dinged for something that isn’t actually a problem.&lt;/p&gt;
&lt;h2 id=&quot;the-aeo-category&quot;&gt;The AEO Category&lt;/h2&gt;
&lt;p&gt;This is the part I’m most interested in. Traditional SEO tools don’t touch this. The AEO checks look at eight things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Whether your robots.txt blocks AI crawlers (GPTBot, ClaudeBot, PerplexityBot, etc.)&lt;/li&gt;
&lt;li&gt;Whether you have an llms.txt file&lt;/li&gt;
&lt;li&gt;Whether your meta description uses generic filler language&lt;/li&gt;
&lt;li&gt;Whether your H1 is descriptive or just “Home”&lt;/li&gt;
&lt;li&gt;Whether you have FAQ/HowTo schema markup&lt;/li&gt;
&lt;li&gt;Whether your H2/H3 headings are phrased as questions&lt;/li&gt;
&lt;li&gt;Whether you have enough content depth for citation&lt;/li&gt;
&lt;li&gt;Whether you have speakable schema for voice assistants&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The AI crawler detection parses robots.txt line by line, tracking user-agent blocks and matching against a list of known crawlers:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; AI_CRAWLERS&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;  &amp;#39;GPTBot&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;ChatGPT-User&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;ClaudeBot&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Claude-Web&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;  &amp;#39;PerplexityBot&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Bytespider&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Google-Extended&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Applebot-Extended&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It’s surprising how many sites block all of these by default. Some hosting platforms and CMS plugins add blanket AI crawler blocks without telling you. Your content is invisible to answer engines and you don’t even know it.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where It Is Now&lt;/h2&gt;
&lt;p&gt;ShipReady is live at &lt;a href=&quot;https://travelvient.com/tools/seo-check/&quot;&gt;travelvient.com/tools/seo-check&lt;/a&gt;. It’s free, rate-limited to 10 scans per hour per IP, and the source is on &lt;a href=&quot;https://github.com/caden311/shipready&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The scoring uses a weighted system where each check has a weight of 1-3, warnings count for half their weight, and categories are weighted by importance. An A is 90+, F is below 60.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d Do Differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Lean harder into AEO.&lt;/strong&gt; Right now it’s 8% of the overall score. That felt right when I was trying to balance against established SEO fundamentals, but honestly, the SEO checks are table stakes at this point. Everyone has a meta description. Not everyone has an llms.txt or question-style headings. The AEO category is the unique value here and it should probably carry more weight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get more people using it.&lt;/strong&gt; I built this for my own sites, but the false positive bugs proved I need a wider range of test cases. Different hosting platforms, different frameworks, different CMS setups. The Cloudflare compression bug only showed up because I was scanning Cloudflare-hosted sites. There are probably similar platform-specific quirks I haven’t hit yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;More actionable fix prompts.&lt;/strong&gt; The current prompts are good but generic. If I could detect the framework (Astro vs Next vs plain HTML) and tailor the fix snippets to that framework’s conventions, the copy-paste experience would be much better.&lt;/p&gt;</content:encoded><category>astro</category><category>cloudflare-workers</category><category>seo</category><category>aeo</category><category>typescript</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/scannerCover.BSr8dewx.png" length="0" type="image/jpeg"/></item><item><title>I Built an AI Trading Bot That Watches Trump&apos;s Truth Social Posts</title><link>https://travelvient.com/blog/building-trump-trader/</link><guid isPermaLink="true">https://travelvient.com/blog/building-trump-trader/</guid><description>I built a bot that reads Trump Truth Social posts and trades the market. Paper trading worked great. Real markets taught me that latency kills everything.</description><pubDate>Sun, 05 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;It started the way these things always do. I was watching the market one afternoon and saw a stock move right after a Truth Social post. Not a subtle drift, either. A real move. And I thought: what if I could catch that automatically?&lt;/p&gt;
&lt;p&gt;Not in a “get rich quick” way. More like a genuine curiosity question. Could an AI read a social media post, figure out which sectors it might affect, and place a trade before the market fully priced it in? I wanted to test the idea. So I built a bot to find out.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;The bot monitors Trump’s Truth Social posts via an RSS feed, sends each post to Claude for sentiment analysis, runs the result through a 9-layer risk management system, and if everything checks out, executes trades through Alpaca’s API. The whole pipeline runs in a loop, polling every 30 seconds.&lt;/p&gt;
&lt;p&gt;Claude reads the post and returns a structured analysis: is this market-relevant? What’s the sentiment? Which sectors are affected? Should we buy, sell, or hold? The response maps to specific ETFs across 19 sectors, from technology (XLK) to defense (ITA) to crypto (BITO). The full source is &lt;a href=&quot;https://github.com/caden311/trader&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;on GitHub&lt;/a&gt; if you want to poke around.&lt;/p&gt;
&lt;p&gt;If the market is closed when a post comes in, the analysis gets queued and the trades execute when the market opens.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;Python was the obvious choice here. The trading ecosystem in Python is mature, and Alpaca’s SDK (&lt;code&gt;alpaca-py&lt;/code&gt;) made the brokerage integration straightforward. Anthropic’s SDK handles the Claude calls. SQLite stores the audit trail because I wanted every decision logged without needing to spin up a database server.&lt;/p&gt;
&lt;p&gt;The whole thing runs in Docker. One container, one &lt;code&gt;docker-compose up -d&lt;/code&gt;, and it’s watching.&lt;/p&gt;
&lt;p&gt;I used Pydantic for config management and data validation. Every environment variable, every risk parameter, every API response gets validated before anything touches real money. Structlog handles logging with enough context to trace any trade back to the original post that triggered it.&lt;/p&gt;
&lt;h2 id=&quot;the-prompt-that-drives-the-whole-thing&quot;&gt;The prompt that drives the whole thing&lt;/h2&gt;
&lt;p&gt;Getting Claude to return consistent, actionable analysis was the core challenge. The system prompt has to be specific enough to produce structured JSON every time, but flexible enough to handle the unpredictable nature of political social media posts.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; f&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&amp;quot;You are a financial analyst AI. Your job is to analyze social media posts&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;from the President of the United States and determine their potential impact on financial markets.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;You must evaluate each post and return a structured analysis. Be conservative in your assessments.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;Only mark posts as relevant if they clearly relate to economic policy, trade, specific industries,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;regulations, international relations affecting markets, or similar market-moving topics.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;Posts about personal matters, birthdays, endorsements of non-market candidates, or general&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;political commentary without clear economic implications should be marked as NOT relevant.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;Be specific about which sectors are affected. If the impact is broad/unclear, use &amp;quot;broad_market&amp;quot;.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;Only suggest &amp;quot;buy&amp;quot; or &amp;quot;sell&amp;quot; when you have reasonable confidence. Default to &amp;quot;hold&amp;quot; when uncertain.&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That “default to hold when uncertain” line does a lot of heavy lifting. Without it, Claude tends to find market relevance in everything.&lt;/p&gt;
&lt;h2 id=&quot;nine-ways-to-say-no&quot;&gt;Nine ways to say no&lt;/h2&gt;
&lt;p&gt;The risk management system was something I was genuinely proud of. Before any trade executes, it has to pass through nine sequential checks. Fail any one of them and the trade gets rejected.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; evaluate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B;font-style:italic&quot;&gt;self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66;font-style:italic&quot;&gt;analysis&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66;font-style:italic&quot;&gt;post_id&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66;font-style:italic&quot;&gt;portfolio_value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66;font-style:italic&quot;&gt;current_positions&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; analysis.confidence &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; settings.confidence_threshold:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; not&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; analysis.is_relevant:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; analysis.direction &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;hold&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.last_trade_time:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        elapsed &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (datetime.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;now&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.last_trade_time).&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;total_seconds&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; elapsed &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; settings.trade_cooldown_seconds:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;            return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; current_positions &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; settings.max_open_positions:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    trades_today &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.db.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;get_trades_today&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;_daily_loss_exceeded&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(trades_today, portfolio_value):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; []&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Confidence threshold, relevance check, direction check, cooldown timer, position limits, daily loss limit, equity floor, buying power buffer, and short exposure cap. Short positions get extra scrutiny: 3% of portfolio max per position versus 5% for longs, tighter stop losses (2% vs 3%), and a 15% cap on total short exposure.&lt;/p&gt;
&lt;p&gt;Every rejection gets logged with the specific reason. I wanted to be able to look back and understand exactly why a trade didn’t happen.&lt;/p&gt;
&lt;h2 id=&quot;gap-protection&quot;&gt;Gap protection&lt;/h2&gt;
&lt;p&gt;One thing that kept me up at night was gap risk. What happens when a post drops at 8pm, the bot queues a trade, and by the time the market opens, the price has already gapped way past where the stop loss should have been?&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; check_gap_positions&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B;font-style:italic&quot;&gt;self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66;font-style:italic&quot;&gt;db&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    positions &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.client.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;get_all_positions&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    closed &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    multiplier &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; settings.gap_protection_multiplier&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; position &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; positions:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        entry_price &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; float&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(position.avg_entry_price)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        current_price &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; float&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(position.current_price)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        gap_threshold &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; stop_pct &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; multiplier&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; is_long &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;and&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; current_price &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; entry_price &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; gap_threshold):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;            self&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.client.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;close_position&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;symbol_or_asset_id&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;symbol)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;            closed.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;append&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(symbol)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; closed&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The gap protection runs every tick during market hours. If a position has moved 1.5x past its stop loss percentage, it force-closes the position immediately. It’s a safety net for the scenario where Alpaca’s bracket order stops haven’t triggered because the price jumped right past them.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;Paper trading worked beautifully. Claude’s analysis was often right. The sector mapping made sense. The risk system caught bad trades. On paper, the strategy was profitable.&lt;/p&gt;
&lt;p&gt;Then I turned on real money and learned a painful lesson about latency.&lt;/p&gt;
&lt;p&gt;The problem is the pipeline. The RSS feed updates every 30 seconds or so. Then Claude needs a few seconds to analyze the post. Then the trade needs to submit and fill. By the time all of that happens, the market has already moved. The people who profit from these posts are the ones who see them in real time, not 30-45 seconds later.&lt;/p&gt;
&lt;p&gt;Paper trading doesn’t punish you for this. Paper fills happen at the price you request. Real markets don’t work that way. The slippage between what I expected to pay and what I actually paid ate into every trade. A move that looked like a 2% gain on paper turned into a 0.5% gain or even a loss in practice.&lt;/p&gt;
&lt;p&gt;The analysis was often correct. Claude would correctly identify that a post about tariffs was bearish for emerging markets, and EEM would indeed drop. But by the time my order filled, the easy money was already gone.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The bot still runs on paper trading. It’s a genuinely interesting system for studying how social media sentiment translates to market movements. The SQLite database has become a decent dataset for analyzing which types of posts actually move markets and which ones are noise.&lt;/p&gt;
&lt;p&gt;As a real trading system, it needs a faster data source to be viable. The strategy itself isn’t broken. The execution speed is.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Ditch the RSS feed entirely.&lt;/strong&gt; RSS was convenient but it’s fundamentally too slow for this use case. Some kind of direct web scraping or websocket connection to Truth Social would cut the detection time from 30+ seconds to near-instant. That alone might make the difference between profitable and not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pre-build analysis patterns.&lt;/strong&gt; Claude is smart, but it’s also slow relative to market speed. If I could build a pattern library from historical posts (tariff mentions = short EEM, deregulation mentions = long XLF), I could skip the API call entirely for common post types and only use Claude for genuinely novel content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start with limit orders instead of market orders.&lt;/strong&gt; Market orders during volatile moments guarantee bad fills. Limit orders might mean some trades don’t execute, but the ones that do would be at prices that actually make the strategy work.&lt;/p&gt;
&lt;p&gt;The core idea, using AI to parse social media for market signals, isn’t wrong. The execution just needs to be measured in milliseconds, not seconds. And that’s a fundamentally different engineering problem than the one I solved.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;more dev experiments&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/&quot;&gt;more devlogs&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>python</category><category>anthropic</category><category>ai</category><category>alpaca</category><category>trading</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/trader.CQLqxPoV.png" length="0" type="image/jpeg"/></item><item><title>Building PackSmart: An AI Packing List With Weather</title><link>https://travelvient.com/blog/building-packsmart/</link><guid isPermaLink="true">https://travelvient.com/blog/building-packsmart/</guid><description>How I built a free AI-powered packing list tool as part of the Travel Vient travel ecosystem, and what I learned about wrangling LLM output into reliable JSON.</description><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;After launching Roamly, I wanted to build out more travel tools under the Travel Vient umbrella. Not everything needs to be a full SaaS product with auth and billing. Sometimes you just need a simple, useful thing that solves one problem well. PackSmart came from that mindset: a free packing list generator that uses AI and real weather data to tell you exactly what to bring on your trip.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;You punch in your destination, travel dates, trip type, luggage size, and planned activities. PackSmart geocodes the destination, pulls weather data for those exact dates, then feeds everything to Claude Haiku to generate a personalized packing list. The list is categorized, quantity-adjusted for your trip length, and comes with destination-specific travel tips.&lt;/p&gt;
&lt;p&gt;You can check items off as you pack, and it saves your progress to localStorage so you can come back later. No account needed, no data stored on a server. Try it out at &lt;a href=&quot;https://travelvient.com/tools/packsmart&quot;&gt;PackSmart&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with App Router, deployed to &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; via OpenNext.js. React 19, Tailwind v4, shadcn/ui components. &lt;strong&gt;Claude Haiku 4.5&lt;/strong&gt; for the AI generation. &lt;strong&gt;Open-Meteo&lt;/strong&gt; for weather data (free, no API key required). &lt;strong&gt;Cloudflare KV&lt;/strong&gt; for distributed rate limiting.&lt;/p&gt;
&lt;p&gt;I went with the same Next.js-on-Workers setup as Roamly because I already had the deployment pipeline figured out. For a tool like this, the edge deployment actually matters. The generate endpoint calls three APIs in sequence (geocoding, weather, Claude), so being physically closer to the user shaves real time off the round trip.&lt;/p&gt;
&lt;p&gt;Open-Meteo was a great find. Free, no auth, solid data. The catch is that their forecast API only goes 16 days out.&lt;/p&gt;
&lt;h2 id=&quot;the-weather-problem&quot;&gt;The weather problem&lt;/h2&gt;
&lt;p&gt;Most packing list generators ignore weather entirely or ask you to describe it yourself. I wanted real data. But Open-Meteo’s forecast horizon is 16 days. If someone’s planning a trip three months from now, I can’t get a forecast.&lt;/p&gt;
&lt;p&gt;The solution: a hybrid approach that blends real forecasts with historical data from the same dates last year.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; async&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; getWeather&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  lat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;lng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  startDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;endDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;WeatherData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; today&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; start&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;startDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; daysUntilStart&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; differenceInDays&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;start&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;today&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;daysUntilStart&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; FORECAST_HORIZON_DAYS&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; forecastEnd&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;today&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    forecastEnd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;forecastEnd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; FORECAST_HORIZON_DAYS&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; end&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;endDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;end&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; forecastEnd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fetchForecast&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;startDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;endDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { ...&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;parseResponse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isHistorical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;false&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;    // Trip spans past forecast horizon, blend both sources&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; forecastEndStr&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; format&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;forecastEnd&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;yyyy-MM-dd&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;forecastData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;historicalData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;all&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;([&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;      fetchForecast&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;startDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;forecastEndStr&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;      fetchHistorical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;forecastEndStr&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;endDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    ]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; forecast&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; parseResponse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;forecastData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; historical&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; parseResponse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;historicalData&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      temperatureMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;forecast&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;temperatureMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, ...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;historical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;temperatureMin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      temperatureMax&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;forecast&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;temperatureMax&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, ...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;historical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;temperatureMax&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      precipitationProbability&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        ...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;forecast&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;precipitationProbability&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        ...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;historical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;precipitationProbability&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      weatherCode&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;forecast&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weatherCode&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, ...&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;historical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weatherCode&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      isHistorical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // Beyond forecast range, use last year&amp;#39;s data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; data&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fetchHistorical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lat&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;startDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;endDate&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { ...&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;parseResponse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isHistorical&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three paths: if the trip is within 16 days, use the real forecast. If the trip starts soon but extends past the horizon, blend forecast and historical data in parallel. If the trip is months away, pull last year’s weather for the same dates. The UI flags when historical data is being used so people know it’s an estimate.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-making-claude-return-clean-json&quot;&gt;The hard part: making Claude return clean JSON&lt;/h2&gt;
&lt;p&gt;This is where most of my time went. Claude Haiku is fast and cheap, which makes it great for a free tool. But getting consistent, parseable JSON out of any LLM is a battle.&lt;/p&gt;
&lt;p&gt;The system prompt is explicit about output format. No markdown, no explanation, just JSON matching a specific schema. And yet.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// Strip markdown code fences if the model wraps the JSON&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; jsonText&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; textBlock&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;jsonText&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;startsWith&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;```&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  jsonText&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; jsonText&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    .&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;replace&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;^&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;```(?:json)&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;\s&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;\n&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    .&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;replace&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/\n&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;```\s&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;$&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// Fix common JSON issues from LLMs: trailing commas before } or ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;jsonText&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; jsonText&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;replace&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/,\s&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;[}&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;\]&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;)/&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;$1&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even with clear instructions, models will occasionally wrap their output in markdown code fences or drop trailing commas into arrays. These two lines handle the most common failures. After that, the response goes through Zod validation to make sure the structure matches what the frontend expects.&lt;/p&gt;
&lt;p&gt;I also sanitize user inputs before they hit the prompt. Destination names are user-provided text that gets interpolated into the prompt, so stripping special characters is a basic defense against prompt injection:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; sanitizeInput&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; text&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;replace&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;[{}&amp;lt;&amp;gt;|&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;`]&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;what-happens-when-the-ai-fails&quot;&gt;What happens when the AI fails&lt;/h2&gt;
&lt;p&gt;Sometimes the API is slow, sometimes it returns garbage, sometimes it’s just down. I didn’t want users staring at an error screen, so there’s a deterministic fallback generator that builds a reasonable packing list from templates:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; fallbackList&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; generateFallbackList&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;tripType&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;luggageType&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;activities&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  data&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;travelers&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// Use real weather summary if we have weather data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;weather&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;temperatureMax&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;formatWeatherSummary&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; import&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;@/lib/weather&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  fallbackList&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weatherSummary&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; formatWeatherSummary&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;weather&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; NextResponse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;json&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  ...&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;fallbackList&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  fallback&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fallback doesn’t count against the rate limit (3 lists per day per IP). If Claude fails, that’s not the user’s fault. The fallback also still uses real weather data if geocoding succeeded, so even the backup list is somewhat personalized.&lt;/p&gt;
&lt;h2 id=&quot;rate-limiting-on-the-edge&quot;&gt;Rate limiting on the edge&lt;/h2&gt;
&lt;p&gt;Since this is a free tool hitting a paid API, rate limiting is essential. Cloudflare KV makes this surprisingly clean for a serverless setup:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; async&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; incrementRateLimit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  ip&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;kv&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;KVNamespace&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Promise&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;void&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; `rate:&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ip&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; entry&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; kv&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;RateLimitEntry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;key&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;json&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; now&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;resetAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; now&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; resetAt&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;now&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    resetAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;setUTCHours&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;24&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; kv&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;put&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      key&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      JSON&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;stringify&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;({ &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;count&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;resetAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;resetAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toISOString&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() }),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;expirationTtl&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;86400&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; kv&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;put&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      key&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      JSON&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;stringify&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;({ &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;count&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;count&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;resetAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;resetAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;expirationTtl&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;86400&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;expirationTtl&lt;/code&gt; on each KV entry means old rate limit records clean themselves up. No cron jobs, no database maintenance. Resets happen at UTC midnight so the behavior is consistent globally.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;PackSmart is live and working. It generates solid packing lists, the weather integration adds real value over generic generators, and the fallback system means it never leaves users empty-handed. It’s doing what I built it for: being a useful free tool in the Travel Vient ecosystem.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Spend more time on prompt engineering upfront.&lt;/strong&gt; I went through a lot of iterations on the system prompt, fixing issues as they came up in production. Things like quantity calculations (7-day trip = 7 pairs of socks), shared vs. personal items, and keeping item names short. Most of those rules in the prompt were added reactively after seeing bad output. A more systematic approach to prompt design from the start would have saved time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use structured outputs from the beginning.&lt;/strong&gt; The JSON sanitization code works, but it’s a band-aid. Anthropic’s API supports structured output modes that would eliminate the markdown-fence and trailing-comma problems entirely. I should have started there instead of adding post-processing hacks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Test with more edge cases early.&lt;/strong&gt; Trips to places with unusual weather patterns, very long trips, destinations with non-English names. The tool handles these now, but each one was a surprise in production rather than something I caught in testing.&lt;/p&gt;
&lt;p&gt;Related: see &lt;a href=&quot;https://travelvient.com/projects/packsmart/&quot;&gt;PackSmart&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>next-js</category><category>ai</category><category>cloudflare</category><category>typescript</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>Building SumTrails: A Daily Number-Path Puzzle Game</title><link>https://travelvient.com/blog/building-sumtrails/</link><guid isPermaLink="true">https://travelvient.com/blog/building-sumtrails/</guid><description>I built a daily number-path puzzle game because I wanted one to play. Turns out, generating good puzzles is exponentially harder than solving them.</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;i-just-wanted-a-daily-puzzle&quot;&gt;I just wanted a daily puzzle&lt;/h2&gt;
&lt;p&gt;I’ve been hooked on the Wordle-style daily puzzle format for a while. One game a day, everyone gets the same one, compare with friends. There’s something about the constraint that makes it satisfying. You can’t binge. You just get your one shot and move on.&lt;/p&gt;
&lt;p&gt;I wanted something in that space but more spatial. Draw paths through a grid of numbers where each path sums to exactly 18. Use every cell. Minimum three cells per line. No diagonal moves. Simple rules, but the grid forces you to think several moves ahead because a greedy line now can strand cells later.&lt;/p&gt;
&lt;p&gt;I couldn’t find a game that scratched that exact itch. So I built &lt;a href=&quot;https://sumtrails.travelvient.com/&quot;&gt;SumTrails&lt;/a&gt;. The full source is on &lt;a href=&quot;https://github.com/caden311/budget-lines&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-game-does&quot;&gt;What the game does&lt;/h2&gt;
&lt;p&gt;You get a 7x7 grid filled with numbers 1 through 9. Your job is to draw connected paths (up, down, left, right) where each path’s numbers add up to 18. Every cell on the grid needs to be part of a completed line. Three cells minimum per line.&lt;/p&gt;
&lt;p&gt;There’s a daily puzzle that resets at 8am Eastern, and a practice mode for when you’ve already finished today’s. The daily puzzle is seeded, so everyone worldwide plays the same grid.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://reactnative.dev&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;React Native&lt;/a&gt; + &lt;a href=&quot;https://expo.dev&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;Expo SDK 54&lt;/a&gt; for cross-platform from one codebase. iOS, Android, and web. I’d already built a few Expo apps and knew the workflow.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.typescriptlang.org&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;TypeScript&lt;/a&gt; in strict mode. For a game with this much state (grid, paths, undo stacks, hints, solution validation), types caught real bugs during development.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://zustand-demo.pmnd.rs/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;Zustand&lt;/a&gt; for state management. Lightweight and lets me keep the game logic as pure functions separate from React.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.swmansion.com/react-native-reanimated/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;React Native Reanimated&lt;/a&gt; for gesture handling. Drawing paths needs to feel instant. More on that below.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/davidbau/seedrandom&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;seedrandom&lt;/a&gt; for deterministic puzzle generation. This is how everyone gets the same daily puzzle.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-generating-puzzles-that-are-actually-fun&quot;&gt;The hard part: generating puzzles that are actually fun&lt;/h2&gt;
&lt;p&gt;The gameplay was straightforward to build. The puzzle generator nearly broke me.&lt;/p&gt;
&lt;p&gt;My first approach was random: fill the grid with numbers, then verify that a valid solution exists. This sounds reasonable until you realize that checking solvability on a 7x7 grid is a combinatorial explosion. Most random grids aren’t solvable at all, and the ones that are tend to be boring, with long straight lines running across the whole board.&lt;/p&gt;
&lt;p&gt;So I flipped it. Instead of generating a grid and hoping it’s solvable, I generate the solution first and derive the grid from it. The algorithm starts from the center of the grid with a seed shape (L, S, Z, spiral, staircase) and tiles outward with random walks. Then it assigns values to each path so they sum to exactly 18:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; assignValuesToPath&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  pathLength&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  targetSum&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  valueRange&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;max&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  rng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: () &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; number&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[] | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;max&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; valueRange&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; minPossible&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; pathLength&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; maxPossible&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; pathLength&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; max&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;targetSum&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; minPossible&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; targetSum&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; maxPossible&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; values&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; remaining&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; targetSum&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; pathLength&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; cellsLeft&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; pathLength&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; minVal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;max&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;remaining&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cellsLeft&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; max&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; maxVal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;max&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;remaining&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;cellsLeft&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;minVal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; maxVal&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; value&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;floor&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;rng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;maxVal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; minVal&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; minVal&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    values&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    remaining&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -=&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  values&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;remaining&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; shuffleArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;values&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rng&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each cell’s value is constrained so the remaining cells can still hit the target. The last cell gets the exact remainder. Shuffle at the end so values don’t cluster predictably.&lt;/p&gt;
&lt;p&gt;But that alone produced boring puzzles. Paths were too straight. The secret sauce was direction-aware scoring during path generation. The algorithm tracks how many horizontal vs. vertical steps it’s taken globally and biases new paths toward the underrepresented direction. It also scores turns higher than straight runs:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; scoredNeighbors&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; neighbors&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;pos&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; score&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; futureNeighbors&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0.8&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;lastDirection&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; isTurn&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      direction&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;row&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; lastDirection&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;row&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      direction&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;col&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; lastDirection&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;col&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isTurn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;score&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2.0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;consecutiveStraight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; isTurn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;score&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 1.5&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;consecutiveStraight&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isTurn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;score&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; -=&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0.5&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // Boost the underrepresented direction globally&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isHorizontalStep&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; bias&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    score&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;abs&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bias&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2.0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isVerticalStep&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; bias&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    score&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;abs&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;bias&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2.0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pos&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;score&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This produces paths that wind and turn, which makes them harder to spot and more satisfying to find.&lt;/p&gt;
&lt;h2 id=&quot;the-daily-puzzle-seed&quot;&gt;The daily puzzle seed&lt;/h2&gt;
&lt;p&gt;Every player gets the same daily puzzle because the generation is deterministic. The puzzle ID is a date string, and it becomes the seed for a pseudo-random number generator:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; generateDailyPuzzle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Date&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;GameState&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; puzzleId&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; getDailyPuzzleId&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; seed&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; `sumtrails-&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;puzzleId&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; generatePuzzleWithSeed&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;seed&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;puzzleId&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;daily&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reset happens at 8am Eastern, not midnight. This was deliberate: I didn’t want the puzzle changing while people are still up playing. The timezone math handles DST manually by calculating the 2nd Sunday of March and 1st Sunday of November. If it’s before 8am Eastern, you get yesterday’s puzzle.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;Early puzzle generation was bad. Not “slightly off” bad. Fundamentally broken.&lt;/p&gt;
&lt;p&gt;The first few versions would produce puzzles where hints would lead you into dead ends. The hint system found a valid line (cells that sum to 18), suggested it, and the player would complete it only to discover the remaining cells couldn’t form any more valid lines. The puzzle was now unsolvable despite the hint being technically correct.&lt;/p&gt;
&lt;p&gt;The fix was to never suggest a line unless the remaining board passes a solvability check:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; maxLinesToCheck&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; line&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; validLines&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; newGrid&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; markCellsAsSpent&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;grid&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;line&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; remainingCells&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; countAvailableCells&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;newGrid&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;remainingCells&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; line&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;hasReasonableSolvability&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;newGrid&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;targetSum&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;minLineLength&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; line&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// IMPORTANT: Do NOT fall back to validLines[0] - that was the bug!&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That comment in the code is there for a reason. I removed the unsafe fallback three separate times before I stopped re-adding it. The temptation to “just return something” when no safe hint exists was strong. But returning an unsafe hint is worse than returning nothing, because it actively misleads the player.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;SumTrails is live on the &lt;a href=&quot;https://sumtrails.travelvient.com/&quot;&gt;web&lt;/a&gt;, iOS, and Android. The daily puzzle works. The generator produces interesting grids consistently. The hint system no longer lies to you.&lt;/p&gt;
&lt;p&gt;It’s a small game. I built it because I wanted a daily puzzle to solve, and now I have one. Some days the 7x7 grid takes me two minutes, some days it takes ten. That variance is what keeps it interesting.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Write tests for the generator from day one.&lt;/strong&gt; I wrote the core game logic tests early, but I treated the puzzle generator as a “get it working and move on” thing. That was a mistake. The hint solvability bug would have been caught immediately by a test that plays through a hint sequence and verifies the board stays solvable. Instead it was a bug report from real usage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Invest in puzzle quality metrics sooner.&lt;/strong&gt; I eventually built scoring that tracks direction balance, path variety, and difficulty. But for the first couple weeks, “does it produce a valid grid” was my only bar. Valid and fun are very different things.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Separate the generator into its own package.&lt;/strong&gt; The puzzle generation code is pure TypeScript with zero React dependencies. It could be its own library, tested and iterated independently. Keeping it in the app repo meant I’d sometimes skip generator improvements because I was focused on UI work.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;more side projects&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/&quot;&gt;more devlogs&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>react-native</category><category>expo</category><category>typescript</category><category>puzzle-generation</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/sumtrails.BM-6VQTl.png" length="0" type="image/jpeg"/></item><item><title>Moving a Client Site Off an AI Builder, Rebuilt From Scratch</title><link>https://travelvient.com/blog/moving-griffin-renovation-off-landingsite/</link><guid isPermaLink="true">https://travelvient.com/blog/moving-griffin-renovation-off-landingsite/</guid><description>LandingSite AI got Griffin Renovation online fast, but SEO limits and vendor lock-in pushed me to rebuild the whole thing in plain HTML, CSS, and JavaScript.</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A few weeks ago I &lt;a href=&quot;https://travelvient.com/blog/building-griffin-renovation&quot;&gt;wrote about shipping Griffin Renovation’s website using LandingSite AI&lt;/a&gt;. The turnaround was fast, the client was happy, and the site looked good. That should have been the end of the story.&lt;/p&gt;
&lt;p&gt;It wasn’t.&lt;/p&gt;
&lt;p&gt;The site was ranking poorly. Griffin Renovation serves Cache Valley, Utah, and for local search queries they were barely showing up. I started digging into why, and the answer was straightforward: I didn’t have enough control over the SEO. Meta tags, structured data, canonical URLs, sitemap configuration. LandingSite handles all of that for you, which is great until you need to change something specific and can’t.&lt;/p&gt;
&lt;p&gt;On top of that, the monthly hosting fee was adding up for what amounted to a static three-page site. The client was paying a recurring cost for infrastructure I could replace with a free Netlify deploy. Between the SEO ceiling and the ongoing cost, it made sense to rebuild.&lt;/p&gt;
&lt;h2 id=&quot;the-hardest-part-wasnt-the-code&quot;&gt;The hardest part wasn’t the code&lt;/h2&gt;
&lt;p&gt;Rebuilding a three-page site in HTML and Tailwind is not complicated. The hardest part was actually getting the domain moved. LandingSite has the hosting locked down, so the only way to transfer is by contacting their support and going back and forth. It’s not a one-click DNS change. That process took longer than writing the entire site.&lt;/p&gt;
&lt;p&gt;Once I had the domain sorted, the actual build was fast. No framework, no build step, no npm. Just HTML files, a CSS file, and two JavaScript files. Deployed to Netlify with a &lt;code&gt;_headers&lt;/code&gt; file for caching and security.&lt;/p&gt;
&lt;h2 id=&quot;taking-control-of-seo&quot;&gt;Taking control of SEO&lt;/h2&gt;
&lt;p&gt;The whole point of the migration was better SEO, so I went deep on structured data. LandingSite gave me basic meta tags but nothing like proper JSON-LD for a local business. Here’s what I added to the homepage:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;https://schema.org&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;HomeAndConstructionBusiness&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Griffin Renovation&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;telephone&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;+1-435-881-7791&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;areaServed&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;City&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Logan&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;containedInPlace&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;State&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Utah&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;City&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Smithfield&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;containedInPlace&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;State&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Utah&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;City&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Hyrum&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;containedInPlace&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;State&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Utah&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;City&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;North Logan&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;containedInPlace&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;State&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Utah&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  &amp;quot;hasOfferCatalog&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    &amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;OfferCatalog&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    &amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Renovation Services&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    &amp;quot;itemListElement&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Offer&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;itemOffered&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Service&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Home Remodels&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Offer&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;itemOffered&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Service&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Drywall Installation &amp;amp; Repair&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s a trimmed version. The full schema includes all eight cities in Cache Valley, all five service types, opening hours, aggregate ratings with individual reviews, and social links. The &lt;code&gt;HomeAndConstructionBusiness&lt;/code&gt; schema type is exactly what Google expects for a local contractor. LandingSite had none of this.&lt;/p&gt;
&lt;p&gt;I also added proper canonical URLs, Open Graph tags, and Twitter Card meta on every page. Plus a real sitemap with priorities and change frequencies instead of whatever LandingSite was generating behind the scenes.&lt;/p&gt;
&lt;h2 id=&quot;reusable-components-without-a-framework&quot;&gt;Reusable components without a framework&lt;/h2&gt;
&lt;p&gt;With only three pages, I didn’t need React or Astro. But I also didn’t want to copy-paste the header and footer into each HTML file. So I went with a simple JavaScript injection pattern:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;addEventListener&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;DOMContentLoaded&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, () &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  injectHeader&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  injectFooter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  initMobileMenu&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;body&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;classList&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;add&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;loaded&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The header and footer are functions that write HTML into placeholder &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; elements. Active nav states are determined from &lt;code&gt;window.location.pathname&lt;/code&gt;. It’s not fancy, but it means updating the navigation is a single-file change instead of editing three HTML files.&lt;/p&gt;
&lt;p&gt;The tradeoff is that search engine crawlers running without JavaScript won’t see the nav. I added a &lt;code&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt; fallback on every page with a plain HTML nav so crawlers and accessibility tools still get the full link structure.&lt;/p&gt;
&lt;h2 id=&quot;preventing-the-flash-of-unstyled-content&quot;&gt;Preventing the flash of unstyled content&lt;/h2&gt;
&lt;p&gt;Since I’m using Tailwind CSS v4 via the browser CDN (no build step), there’s a moment before Tailwind processes the styles where the page looks broken. A small CSS trick handles it:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;body&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  opacity: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  transition: opacity &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.15&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; ease-in&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;body&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.loaded&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  opacity: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;loaded&lt;/code&gt; class gets added by JavaScript after the components inject. The page fades in smoothly instead of flashing raw HTML for a split second.&lt;/p&gt;
&lt;h2 id=&quot;the-contact-form&quot;&gt;The contact form&lt;/h2&gt;
&lt;p&gt;LandingSite’s built-in contact form was one of its best features. Replacing it meant finding a solution that didn’t require a backend. I went with EmailJS, which sends emails directly from the browser using their API:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;form&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;addEventListener&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;submit&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;e&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  e&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;preventDefault&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; form&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelector&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;#full-name&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; email&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; form&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelector&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;#email&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; message&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; form&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelector&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;#message&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;email&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    statusMsg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;textContent&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;Please fill in all required fields.&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    statusMsg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;className&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;text-red-500 text-sm mt-4&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  submitBtn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;disabled&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  submitBtn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;textContent&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;Sending...&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  try&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; emailjs&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;send&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;EMAILJS_SERVICE_ID&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;EMAILJS_TEMPLATE_ID&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      from_name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;from_email&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;email&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;phone&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;message&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    statusMsg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;textContent&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;Message sent successfully! We&amp;#39;ll be in touch soon.&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    form&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;reset&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;catch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;error&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    statusMsg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;textContent&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;#39;Something went wrong. Please call us directly at (435) 881-7791.&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The error fallback displays the phone number directly. For a renovation company, if the form breaks, the client still needs a way to reach them. That matters more than a retry button.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The site is live at &lt;a href=&quot;https://www.griffinrenovation.com/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;griffinrenovation.com&lt;/a&gt;. Three pages, no build process, deployed free on Netlify. Images are served through Cloudflare’s image delivery CDN for automatic compression and WebP conversion. Security headers and aggressive caching are handled by a &lt;code&gt;_headers&lt;/code&gt; file. The client stopped paying a monthly fee and got better SEO in the process.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Skip the AI builder entirely.&lt;/strong&gt; I wrote about LandingSite positively in the first post, and I stand by that for certain use cases. But for a client where SEO matters and you know you’ll want full control eventually, the AI builder is just an extra step. The time I saved on the initial build, I spent on the migration. If I’d written the HTML from the start, the total hours would have been about the same, and I wouldn’t have had to deal with the domain transfer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start with structured data from day one.&lt;/strong&gt; The JSON-LD schema was the single biggest SEO improvement. I should have had that on the LandingSite version too, even if it meant injecting it through their advanced editor. Local business schema with area served, services, and reviews is table stakes for a contractor competing in local search.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use a static site generator for anything beyond three pages.&lt;/strong&gt; Plain HTML worked here because the site is tiny. If Griffin Renovation wanted a blog, a project portfolio, or more service pages, I’d reach for Astro. The JavaScript component injection pattern is fine for three pages but wouldn’t scale well.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/griffin-renovation/&quot;&gt;Griffin Renovation&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/building-griffin-renovation/&quot;&gt;the original build log&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>html</category><category>tailwind</category><category>seo</category><category>client-work</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>Building a Website for a 40-Year-Old Hair Salon</title><link>https://travelvient.com/blog/building-total-eclips/</link><guid isPermaLink="true">https://travelvient.com/blog/building-total-eclips/</guid><description>A local salon in Smithfield, Utah had been cutting hair for four decades without a website. Here&apos;s how I built one from scratch with vanilla HTML, CSS, and JS.</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A local hair salon in Smithfield, Utah reached out and needed a website. Total E’Clips has been cutting hair for over 40 years, serving families across Cache Valley. Three stylists, a loyal client base, and not a single page on the internet. No Google Business profile to speak of, no social media presence beyond a Facebook page, nothing. They wanted something simple that told people who they are, what they offer, and how to book.&lt;/p&gt;
&lt;p&gt;The catch: there was almost nothing to work with. No existing branding, no professional photos, no written copy. Just a business name, a phone number, and 40 years of word-of-mouth reputation.&lt;/p&gt;
&lt;h2 id=&quot;what-the-site-needed-to-do&quot;&gt;What the site needed to do&lt;/h2&gt;
&lt;p&gt;This isn’t a SaaS product or a portfolio piece. It’s a small-town salon. The site needed to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Show what services they offer (cuts, color, nails, waxing)&lt;/li&gt;
&lt;li&gt;Make it dead simple to call and book&lt;/li&gt;
&lt;li&gt;Look professional enough to build trust with new customers searching online&lt;/li&gt;
&lt;li&gt;Work perfectly on phones, because that’s how most people will find it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I went with plain HTML, CSS, and vanilla JavaScript. No frameworks, no build tools, no npm install. For a single-page site like this, shipping three files is the right call. It loads instantly, there’s nothing to break, and anyone can open the code and understand it.&lt;/p&gt;
&lt;h2 id=&quot;the-design-system&quot;&gt;The design system&lt;/h2&gt;
&lt;p&gt;I wanted the site to feel warm and premium without looking like a generic template. The color palette centers on a warm tan/gold accent against charcoal backgrounds. All defined as CSS custom properties so the entire theme can be updated by changing a few values:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;:root&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --color-dark&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#2C2C2C&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --color-light&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#FAF8F5&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --color-accent&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#C4A882&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --color-accent-hover&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#A8896A&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --color-body&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#4A4A4A&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --color-text-light&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#F5F0EB&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --font-heading&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Bebas Neue&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;sans-serif&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  --font-body&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;Raleway&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;sans-serif&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bebas Neue for headings gives it that bold, modern salon feel. Raleway at weight 300 for body text keeps everything light and readable. Two fonts, one accent color, and the whole site feels cohesive.&lt;/p&gt;
&lt;h2 id=&quot;scroll-animations-without-a-library&quot;&gt;Scroll animations without a library&lt;/h2&gt;
&lt;p&gt;I wanted elements to fade in as you scroll down the page. Most people reach for a library for this. You don’t need one. The IntersectionObserver API does exactly this in about 12 lines of JavaScript:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; observer&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; IntersectionObserver&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;entries&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  entries&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;forEach&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isIntersecting&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;target&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;classList&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;add&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;is-visible&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      observer&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;unobserve&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;target&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;threshold&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;document&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;querySelectorAll&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;#39;.animate-on-scroll&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;forEach&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;el&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  observer&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;observe&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;el&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The CSS side is just as simple. Elements start translated down and invisible, then transition into place:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.animate-on-scroll&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  opacity: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  transform: &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;translateY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  transition: opacity &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.6&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; ease&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, transform &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.6&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; ease&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.animate-on-scroll.is-visible&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  opacity: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  transform: &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;translateY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key detail: &lt;code&gt;observer.unobserve(entry.target)&lt;/code&gt; after the animation fires. Each element animates once and then the observer stops watching it. No wasted cycles on elements that already appeared.&lt;/p&gt;
&lt;h2 id=&quot;staggered-card-animations&quot;&gt;Staggered card animations&lt;/h2&gt;
&lt;p&gt;The service cards cascade in one by one instead of all appearing at once. This is pure CSS, no JavaScript timing needed. Each card gets a slightly longer transition delay:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.service-card&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;:nth-child&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { transition-delay: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.1&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.service-card&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;:nth-child&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { transition-delay: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.2&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.service-card&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;:nth-child&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;4&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { transition-delay: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.3&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.service-card&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;:nth-child&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;5&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { transition-delay: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.4&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.service-card&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;:nth-child&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;6&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { transition-delay: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0.5&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the IntersectionObserver triggers &lt;code&gt;is-visible&lt;/code&gt; on each card, the delays mean they appear in sequence. It’s a small touch that makes the page feel alive instead of static.&lt;/p&gt;
&lt;h2 id=&quot;the-hero-section&quot;&gt;The hero section&lt;/h2&gt;
&lt;p&gt;The hero uses a CSS gradient background with an SVG pattern overlay for texture. No images needed, so it loads instantly and scales to any screen:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;.hero&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  position: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;relative&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  height: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;100&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;vh&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  min-height: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;600&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;px&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  display: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;flex&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  align-items: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;center&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  justify-content: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;center&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  background: &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;linear-gradient&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;135&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;deg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#2C2C2C&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#5C4A3A&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 40&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;#C4A882&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 100&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  overflow: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;hidden&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A subtle cross pattern is layered on top at 3% opacity using an inline SVG data URI in the &lt;code&gt;::before&lt;/code&gt; pseudo-element. You barely notice it, but it adds depth that a flat gradient alone doesn’t have.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-wasnt-code&quot;&gt;The hard part wasn’t code&lt;/h2&gt;
&lt;p&gt;The code came together fast. The actual challenge was content. When a business has had zero internet presence for 40 years, there’s nothing to reference. No existing copy to refine, no “About Us” page to rewrite, no photos to pull from.&lt;/p&gt;
&lt;p&gt;I had to piece together the story from conversations. Who are the stylists? How long have they been there? What’s the vibe of the place? The copy needed to sound like a real neighborhood salon, not a marketing template. Lines like “Your neighborhood salon for over 40 years” worked because they’re true and specific. Generic phrases like “providing excellent service” would have said nothing.&lt;/p&gt;
&lt;p&gt;Getting the testimonials right took multiple rounds too. Real words from real clients, not AI-generated filler.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Use a static site generator.&lt;/strong&gt; Vanilla HTML was fine for this scope, but if the salon ever wants a second page or a blog, editing raw HTML gets tedious fast. Astro would have been a natural fit. Still static output, still fast, but with components and templating that make updates easier.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get all the details upfront.&lt;/strong&gt; I went back and forth more than I should have on service descriptions, the “About” section copy, and contact details. Next time I’d send a structured questionnaire before writing a single line of code. Business hours, services with descriptions, team bios, payment methods, accessibility info. Collect it all in one pass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Push harder on photography.&lt;/strong&gt; The &lt;code&gt;images/&lt;/code&gt; folder is empty. The site works without photos, the gradient hero and SVG icons carry the visual weight. But a few real photos of the salon interior and the stylists at work would add a level of authenticity that design alone can’t replicate.&lt;/p&gt;
&lt;h2 id=&quot;where-it-stands&quot;&gt;Where it stands&lt;/h2&gt;
&lt;p&gt;The site is live and does what it needs to do. It gives Total E’Clips a real online presence for the first time in their 40-year history. Customers can find them, see what they offer, and call to book, all from their phone. The entire thing is three files: one HTML, one CSS, one JS. No dependencies, no build step, nothing to maintain.&lt;/p&gt;
&lt;p&gt;Sometimes the right tool for the job is the simplest one.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/total-eclips/&quot;&gt;Total Eclips&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;more side projects&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>html</category><category>css</category><category>javascript</category><category>web-design</category><category>client-work</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/totaleclips.CiDYIvp8.png" length="0" type="image/jpeg"/></item><item><title>Building Roamly: AI-Powered Group Travel Planning</title><link>https://travelvient.com/blog/building-roamly/</link><guid isPermaLink="true">https://travelvient.com/blog/building-roamly/</guid><description>How I turned the frustration of planning a trip with friends into a full-stack app that uses Claude AI to find destinations everyone can actually agree on.</description><pubDate>Sun, 15 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every time my friend group tries to plan a trip, it falls apart the same way. Someone throws out a city, someone else says it’s too expensive, a third person can’t make those dates, and a fourth person has already been there and doesn’t want to go back. Three weeks of back-and-forth in a group chat later, we either settle on somewhere nobody’s that excited about or give up entirely.&lt;/p&gt;
&lt;p&gt;I’ve been on both sides of this. I’ve been the one with strong opinions that kill momentum. I’ve been the one who just wants something on the calendar and agrees to whatever. Neither feels great. So I built Roamly.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;Roamly is a group travel planner. You create a group, invite your friends, and everyone privately fills out their preferences: where they want to go, where they don’t, what their budget is, what dates work for them, how adventurous they’re feeling. When everyone’s ready, the group planner triggers an AI search. Claude reads all those preferences, does some web research, and generates a set of destination recommendations with full day-by-day itineraries tailored to the group.&lt;/p&gt;
&lt;p&gt;The key word is privately. Nobody sees what anyone else submitted until after the AI runs. That keeps people honest instead of anchoring to whoever spoke first.&lt;/p&gt;
&lt;p&gt;You can try it at &lt;a href=&quot;https://roamly.travelvient.com&quot;&gt;roamly.travelvient.com&lt;/a&gt;. Check out the &lt;a href=&quot;https://travelvient.com/projects/roamly/&quot;&gt;Roamly project page&lt;/a&gt; for a full feature overview.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 15&lt;/strong&gt; (App Router) deployed to Cloudflare Workers via OpenNext&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase&lt;/strong&gt; for auth, database, and real-time subscriptions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Anthropic Claude&lt;/strong&gt; for itinerary generation, with streaming responses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stripe&lt;/strong&gt; for subscription billing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind v4&lt;/strong&gt; and shadcn-style components for the UI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The real-time piece is important. When members fill out their preferences, every other person in the group sees their status update live. No polling, no refreshing. Supabase’s Postgres change subscriptions handle it cleanly:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; channel&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; supabase&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  .&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;channel&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`group-prefs-&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;groupId&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  .&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;on&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;    &amp;quot;postgres_changes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      event&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;*&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      schema&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;public&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      table&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;member_preferences&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      filter&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`group_id=eq.&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;groupId&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;payload&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;payload&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;eventType&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;UPDATE&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;        setPreferences&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;prev&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;          prev&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;            p&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;user_id&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;payload&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;new&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; as&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; MemberPreferences&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;user_id&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;              ?&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;payload&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;new&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; as&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; MemberPreferences&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;              :&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; p&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;          )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;        );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  .&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;subscribe&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One subscription, three event types handled, no full refetch. It just works.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-getting-the-ai-to-behave&quot;&gt;The hard part: getting the AI to behave&lt;/h2&gt;
&lt;p&gt;The core feature is the AI search, and it was the hardest thing to get right. The goal is simple: take a group’s mixed preferences and produce a useful, structured itinerary JSON. The reality is that language models are not naturally reliable at this.&lt;/p&gt;
&lt;p&gt;Early outputs were all over the place. Destinations that blew someone’s budget. Missing fields that caused the UI to crash. Hallucinated dates. Responses that ignored explicit exclusions like “no beach destinations.”&lt;/p&gt;
&lt;p&gt;The fix was iterative and unglamorous: better constraints in the system prompt, explicit hard rules around budget and exclusions, and treating the JSON schema as a contract that the model had to follow. I also built in a credit refund system for cases where the model hits token limits or refuses a request. Users shouldn’t lose a search credit because Claude decided to truncate at 8,000 tokens.&lt;/p&gt;
&lt;p&gt;The model selection is tiered by subscription level. Three Claude models mapped to three tiers:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; AVAILABLE_MODELS&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ModelConfig&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[] &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    id&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;claude-haiku-4-5-20251001&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    label&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;AI Basic&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    tier&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;budget&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    description&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Quick results, good for exploration&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    id&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;claude-sonnet-4-5&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    label&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;AI+&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    tier&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;standard&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    description&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Great quality and detail&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    id&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;claude-opus-4-5&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    label&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;AI Pro+&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    tier&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;premium&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    description&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;Most thorough itineraries&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Haiku is fast and free. Opus takes longer but produces noticeably richer itineraries. Most people will land on Sonnet.&lt;/p&gt;
&lt;h2 id=&quot;streaming-and-recovery&quot;&gt;Streaming and recovery&lt;/h2&gt;
&lt;p&gt;AI responses take time. For a complex group itinerary, Claude can run for 15-30 seconds. Showing a blank screen that long is not acceptable, so I stream the response directly to the client as it generates. The UI shows the tail of the stream in real time so users know something is happening.&lt;/p&gt;
&lt;p&gt;The trickier problem is what happens when the stream gets interrupted. User closes the tab, network drops, phone locks. I track active searches in localStorage with a 120-second TTL:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; getActiveSearch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  groupId&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;  userId&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ActiveSearch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  try&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; raw&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; localStorage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;STORAGE_KEY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;raw&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ActiveSearch&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; JSON&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;parse&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;raw&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;groupId&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; groupId&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;triggeredBy&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; userId&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; age&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;now&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; Date&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;startedAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getTime&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;age&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 120_000&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;      localStorage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;removeItem&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;STORAGE_KEY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; entry&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;catch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a user lands back on the search page and a recent search marker exists, the app polls the database every 3 seconds for up to 30 attempts looking for a saved result. If it finds one, it loads it. If not, it shows a failure state. The user doesn’t lose their credit either way on a connection error.&lt;/p&gt;
&lt;h2 id=&quot;what-went-wrong-cloudflare&quot;&gt;What went wrong: Cloudflare&lt;/h2&gt;
&lt;p&gt;Deploying Next.js to Cloudflare was rougher than I expected. The first assumption I made was wrong: Next.js cannot be deployed as a Cloudflare Pages project the normal way. It has to run as a Worker, using OpenNext as the adapter. The config itself ends up being trivially simple:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;defineCloudflareConfig&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;@opennextjs/cloudflare&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; default&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; defineCloudflareConfig&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But getting there involved a lot of failing builds and confusing error messages.&lt;/p&gt;
&lt;p&gt;The other headache was secrets. Environment variables that work fine in Vercel don’t automatically show up at runtime in Cloudflare Workers. You have to use &lt;code&gt;wrangler secret put&lt;/code&gt; to push them, and the compatibility flags in &lt;code&gt;wrangler.toml&lt;/code&gt; matter for Node.js APIs to work at all:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;toml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;compatibility_flags&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; = [&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;nodejs_compat&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;nodejs_compat_populate_process_env&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That second flag, &lt;code&gt;nodejs_compat_populate_process_env&lt;/code&gt;, is the one that actually makes &lt;code&gt;process.env&lt;/code&gt; work. Without it, all your secrets are undefined at runtime and you get a wall of cryptic auth errors. I spent more time than I’d like to admit figuring that out.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;Roamly is live and free to use. There’s a paid tier that unlocks more monthly searches and access to better models. It’s early. The user base is small. But the core loop works, and I’ve actually used it with my own friends to plan a trip, which was the original goal.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;If I started over, I’d use plain React instead of Next.js. Not because Next.js is bad, but because Cloudflare Workers is where I wanted to deploy from the start, and the Next.js-on-Workers story involves the OpenNext adapter as a middle layer. That layer works, but it’s an extra thing to maintain and debug. A Vite-based React app with a separate API layer would have been faster to ship and easier to reason about on the edge.&lt;/p&gt;
&lt;p&gt;The second thing I’d change is how I approached the AI prompting. I iterated my way to something that works, but it took longer than it needed to because I didn’t think clearly enough about the output contract upfront. Starting with the JSON schema and working backwards to the prompt would have saved a few frustrating weeks.&lt;/p&gt;
&lt;p&gt;The group travel problem is real. Roamly doesn’t solve all of it, but it solves the part where nobody can agree on where to go. That’s a start.&lt;/p&gt;
&lt;p&gt;Related: see &lt;a href=&quot;https://travelvient.com/projects/roamly/&quot;&gt;Roamly&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>next-js</category><category>supabase</category><category>ai</category><category>cloudflare</category><category>stripe</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/roamly.z8GHDJLB.png" length="0" type="image/jpeg"/></item><item><title>I Built a Pipeline to Generate YouTube Shorts Programmatically</title><link>https://travelvient.com/blog/generating-youtube-shorts-with-ai/</link><guid isPermaLink="true">https://travelvient.com/blog/generating-youtube-shorts-with-ai/</guid><description>A TypeScript CLI that takes a script and spits out a finished YouTube Short using Claude, Fal.ai, ElevenLabs, and FFmpeg, no video editor required.</description><pubDate>Sat, 14 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I got bored one weekend and started wondering: how hard would it actually be to generate a YouTube Short entirely from code? Not screen-record something, not stitch clips manually, but write a script in a text file and have a program hand you back a finished &lt;code&gt;.mp4&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Turns out: not that hard. Also not free. Here’s what I built, how it works, and what I’d do differently.&lt;/p&gt;
&lt;p&gt;You can find the full code on GitHub: &lt;a href=&quot;https://github.com/caden311/content-generator&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;github.com/caden311/content-generator&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here’s an example of one of the Shorts it generated: &lt;a href=&quot;https://www.youtube.com/shorts/pbITB7jEUzc&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;youtube.com/shorts/pbITB7jEUzc&lt;/a&gt;&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-it-actually-does&quot;&gt;What it actually does&lt;/h2&gt;
&lt;p&gt;The pipeline takes a plain text script as input and runs it through five stages:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Scene breakdown&lt;/strong&gt;, Claude reads your script and returns a JSON breakdown with one scene per segment. Each scene gets an image prompt, a video prompt, narration text, and a target duration in seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Asset generation&lt;/strong&gt;, For every scene, the pipeline fires off image generation (Fal FLUX), video generation (Fal Kling), and text-to-speech (ElevenLabs) in parallel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subtitle generation&lt;/strong&gt;, Each audio file gets transcribed by Whisper to get word-level timestamps, which are converted to an ASS subtitle file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Assembly&lt;/strong&gt;, FFmpeg concatenates the clips, merges the voiceover, and burns in the subtitles.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;YouTube metadata&lt;/strong&gt;, Claude generates a title, description, and tags, saved to &lt;code&gt;upload.json&lt;/code&gt; next to the video.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The whole thing runs from one command:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; run&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; generate&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; --&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; my-script.txt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; --format&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; shorts&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Claude&lt;/strong&gt; handles the script-to-scenes breakdown. I gave it a system prompt that asks for strict JSON output with image prompts, video prompts, and narration split per scene. Using a structured prompt instead of freeform prose made parsing predictable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fal.ai&lt;/strong&gt; runs both FLUX (images) and Kling (video generation). Fal uses an async queue model: you submit a job, get back a &lt;code&gt;status_url&lt;/code&gt; and &lt;code&gt;response_url&lt;/code&gt;, then poll until it finishes. This is fine for a CLI but means each video clip can take a few minutes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ElevenLabs&lt;/strong&gt; handles the narration voice. The default voice is Rachel (&lt;code&gt;21m00Tcm4TlvDq8ikWAM&lt;/code&gt;) from their multilingual v2 model. You can swap it with the &lt;code&gt;--voice&lt;/code&gt; flag.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OpenAI Whisper&lt;/strong&gt; transcribes the generated audio back to word-level timestamps, which power the subtitles. Yes, you generate speech and then transcribe it. The timestamps are accurate enough that it works.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FFmpeg&lt;/strong&gt; does the final assembly. If a video clip failed to generate, it falls back to a still image with a Ken Burns zoom effect so the video doesn’t look dead.&lt;/p&gt;
&lt;p&gt;The adapters are all behind interfaces, so swapping one provider for another means writing one new class. Three tiers are built in: &lt;code&gt;budget&lt;/code&gt;, &lt;code&gt;standard&lt;/code&gt;, and &lt;code&gt;premium&lt;/code&gt;, each mapping to a different adapter set.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;getting-everything-talking-to-claude&quot;&gt;Getting everything talking to Claude&lt;/h2&gt;
&lt;p&gt;The trickiest design decision was the scene breakdown prompt. I needed Claude to return consistent JSON every time, with scenes that summed to under 60 seconds for Shorts format.&lt;/p&gt;
&lt;p&gt;The fix was simple: inject a &lt;code&gt;{{MAX_DURATION_INSTRUCTION}}&lt;/code&gt; placeholder into the system prompt that only gets filled in when you’re targeting 9:16 format.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// src/adapters/llm/claude.ts&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; SYSTEM_PROMPT&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; `You are a video production assistant...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;{{MAX_DURATION_INSTRUCTION}}`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; breakdownScript&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;script&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;maxDurationSeconds&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;?:&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; maxDurationInstruction&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; maxDurationSeconds&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; !==&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; undefined&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    ?&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; `- IMPORTANT: Total duration MUST NOT exceed &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;maxDurationSeconds&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; seconds`&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    :&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; systemPrompt&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; SYSTEM_PROMPT&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;replace&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;    &amp;quot;{{MAX_DURATION_INSTRUCTION}}&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    maxDurationInstruction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude also occasionally wraps the JSON in a markdown code block. The response parser strips that before calling &lt;code&gt;JSON.parse&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; jsonMatch&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; jsonStr&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;match&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/```(?:json)&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;?&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;\s&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;\s\S&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;]*?&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;)```/&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;jsonMatch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;?.[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  jsonStr&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; jsonMatch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Small thing, but it would silently break without it.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-hard-part-timing&quot;&gt;The hard part: timing&lt;/h2&gt;
&lt;p&gt;Getting audio, video, and subtitles to sync up correctly was messier than I expected.&lt;/p&gt;
&lt;p&gt;Each scene has a &lt;code&gt;durationSeconds&lt;/code&gt; from Claude’s breakdown. But the actual generated audio is rarely exactly that long. ElevenLabs paces speech differently depending on the narration content, and Kling generates clips in fixed 5 or 10 second chunks regardless of what you asked for.&lt;/p&gt;
&lt;p&gt;The subtitle system handles this by measuring the real audio duration. Whisper returns actual word timestamps, and the subtitle generator tracks a running &lt;code&gt;offset&lt;/code&gt; that accumulates across scenes based on the &lt;code&gt;durationSeconds&lt;/code&gt; field, not the real audio length. That mismatch meant subtitles could drift by a second or two on longer videos.&lt;/p&gt;
&lt;p&gt;Here’s how the offset calculation works in the orchestrator:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// src/pipeline/orchestrator.ts&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; offset&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; sceneAudioInfos&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; asset&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; of&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; sortedAssets&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; scene&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; project&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;breakdown&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;scenes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;asset&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;sceneIndex&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;asset&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;audioPath&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    sceneAudioInfos&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;push&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      narration&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;scene&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;narration&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      audioPath&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;asset&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;audioPath&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      offsetSeconds&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;offset&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,        &lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// cumulative offset passed to Whisper&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;  offset&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; scene&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;durationSeconds&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;  &lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// uses target duration, not real duration&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fix would be to measure actual audio duration with &lt;code&gt;ffprobe&lt;/code&gt; and use that for the offset instead. I didn’t get around to it.&lt;/p&gt;
&lt;p&gt;The final assembly step trims the output to &lt;code&gt;Math.min(videoDuration, audioDuration)&lt;/code&gt; to avoid a silent tail if the audio runs shorter than the video. That part at least works cleanly.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-went-wrong-subtitles&quot;&gt;What went wrong: subtitles&lt;/h2&gt;
&lt;p&gt;The subtitle burn-in was the most frustrating part of the whole project.&lt;/p&gt;
&lt;p&gt;FFmpeg can burn ASS subtitles using a &lt;code&gt;libass&lt;/code&gt; filter. The command looks like:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;ffmpeg&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; -vf&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;ass=filename=subtitles.ass&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; ...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But &lt;code&gt;libass&lt;/code&gt; is not included in the default Homebrew FFmpeg build on macOS. You get a cryptic “No such filter” error at runtime. The workaround is:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;brew&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; libass&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;brew&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; reinstall&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; ffmpeg&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The pipeline now catches the specific error string and logs a helpful message instead of crashing:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// src/assembly/ffmpeg.ts&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;} &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;catch&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;err&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;any&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;err&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;?.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;stderr&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;?.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;includes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;No such filter&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    logger&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;warn&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;      &amp;quot;ffmpeg built without libass, subtitles skipped. &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#98C379&quot;&gt;      &amp;quot;Fix: brew install libass &amp;amp;&amp;amp; brew reinstall ffmpeg&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    throw&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; err&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If subtitle burning fails, the video still gets assembled, just without the text overlay. For Shorts this matters a lot since most people watch without sound.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;how-to-try-it-yourself&quot;&gt;How to try it yourself&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Node.js 22+&lt;/li&gt;
&lt;li&gt;FFmpeg with libass: &lt;code&gt;brew install libass &amp;amp;&amp;amp; brew install ffmpeg&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;API keys for Anthropic, OpenAI, Fal.ai, and ElevenLabs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; clone&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; https://github.com/caden311/content-generator&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;cd&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; content-generator&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; install&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; file:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;ANTHROPIC_API_KEY=your_key&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;OPENAI_API_KEY=your_key&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;FAL_KEY=your_key&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;ELEVENLABS_API_KEY=your_key&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Write a script.&lt;/strong&gt; Plain text, narration style, a few paragraphs. The shorter the better for Shorts. Save it as &lt;code&gt;script.txt&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generate:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;# YouTube Short (9:16, 60s max)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; run&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; generate&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; --&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; script.txt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; --format&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; shorts&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;# Standard YouTube video (16:9)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; run&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; generate&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; --&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; script.txt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;# Dry run (no API calls, generates placeholder media)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;npm&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; run&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; generate&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; --&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; script.txt&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; --dry-run&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output lands in &lt;code&gt;./output/001_your-video-title/output.mp4&lt;/code&gt; alongside a &lt;code&gt;breakdown.json&lt;/code&gt;, &lt;code&gt;upload.json&lt;/code&gt; with YouTube metadata, and all the intermediate assets.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Model tiers:&lt;/strong&gt;&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tier&lt;/th&gt;&lt;th&gt;Images&lt;/th&gt;&lt;th&gt;TTS&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;budget&lt;/code&gt;&lt;/td&gt;&lt;td&gt;FLUX Schnell&lt;/td&gt;&lt;td&gt;OpenAI TTS&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;standard&lt;/code&gt;&lt;/td&gt;&lt;td&gt;FLUX Schnell&lt;/td&gt;&lt;td&gt;ElevenLabs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;premium&lt;/code&gt;&lt;/td&gt;&lt;td&gt;DALL-E 3&lt;/td&gt;&lt;td&gt;ElevenLabs&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;All tiers use Kling for video generation. You can switch with &lt;code&gt;--tier premium&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cost estimate:&lt;/strong&gt; A standard 4-scene Short runs roughly $0.30-0.60 depending on the tier. Most of that is Kling. Image and TTS costs are small.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;It works. The output quality is… fine. Kling generates reasonably coherent motion clips. The ElevenLabs voice sounds natural. The visuals are a little random since there’s no style consistency between scenes, but for a weekend experiment it’s genuinely impressive that it works at all.&lt;/p&gt;
&lt;p&gt;The project is not something I’m actively maintaining. It was a curiosity project that answered its question: yes, you can generate short-form video content from a text file in an afternoon. Whether the content is actually good is a separate problem.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Add background music.&lt;/strong&gt; The biggest thing missing from the generated Shorts is audio atmosphere. The narration sits on dead silence, which feels unpolished. Adding a royalty-free background track and ducking it under the voiceover would make a meaningful difference to the final feel.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Measure real audio duration for subtitle offsets.&lt;/strong&gt; As mentioned above, using the target &lt;code&gt;durationSeconds&lt;/code&gt; from Claude instead of the actual audio file length causes subtitle drift. A single &lt;code&gt;ffprobe&lt;/code&gt; call per scene at assembly time would fix it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add a visual style constraint to image prompts.&lt;/strong&gt; Right now each scene generates an image independently, so the visual style can jump around. Injecting a consistent style prefix into every image prompt (something like “cinematic, warm color grading, consistent lighting”) would make multi-scene videos look less like a random slideshow.&lt;/p&gt;
&lt;p&gt;The underlying approach is solid. The pipeline architecture, the parallel asset generation, the adapter pattern for swapping providers: all of that held up. The rough edges are mostly surface-level quality problems that more prompt engineering and one or two extra processing steps would fix.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/blog/&quot;&gt;more devlogs&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;side projects&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>devlog</category><category>typescript</category><category>ai</category><category>youtube-shorts</category><category>claude</category><category>fal-ai</category><category>elevenlabs</category><category>ffmpeg</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Built a WoW Addon to Automate My Portal Business</title><link>https://travelvient.com/blog/building-mage-portals/</link><guid isPermaLink="true">https://travelvient.com/blog/building-mage-portals/</guid><description>How selling mage portals for gold in WoW Classic led to writing Lua, fighting the minimap API, and learning why Trade chat is a disaster for pattern matching.</description><pubDate>Sat, 07 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you’ve played World of Warcraft Classic, you know mages are the taxi service of Azeroth. Stand in a capital city, spam “/yell WTS ports to all cities”, wait for people to type “WTB port org” in chat, invite them, cast the portal, collect gold. Repeat indefinitely.&lt;/p&gt;
&lt;p&gt;I was doing this. And I kept missing requests. Someone would yell “WTB port” and by the time I noticed and typed their name into the invite dialog, they’d already moved on or found another mage. So I did what any programmer would do: I spent several hours building a tool to automate a task that costs maybe 5 seconds per attempt.&lt;/p&gt;
&lt;p&gt;But honestly it was more than just efficiency. I also wanted to learn WoW addon development. And I genuinely wanted a tool like this to exist. So: all three motivations, all at once.&lt;/p&gt;
&lt;p&gt;The result is &lt;a href=&quot;https://github.com/caden311/mage-portals&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;mage-portals&lt;/a&gt;: a WoW Classic addon that watches chat for portal requests and auto-invites the buyer. No more missed sales.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;The addon listens to &lt;code&gt;/say&lt;/code&gt;, &lt;code&gt;/yell&lt;/code&gt;, and General (&lt;code&gt;/1&lt;/code&gt;) by default. When it sees a message containing trigger words (“WTB” or “LF”) combined with portal words (“port”, “ports”, “portal”, “portals”), it fires an invite to that player automatically.&lt;/p&gt;
&lt;p&gt;It also tries to detect which portal they want. If someone says “WTB port to uc”, the addon can log that it’s Undercity and optionally whisper them a confirmation. There’s a throttle so the same player doesn’t get spammed with repeat invites. It checks whether you’re in a raid (and whether you’re the leader or assistant), because only certain roles can invite.&lt;/p&gt;
&lt;p&gt;There’s a minimap button with a portal icon. Right-click opens the config. Left-click toggles the addon on and off.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;Lua. That’s it. WoW addons are Lua all the way down. There’s no package manager, no build step, no bundler. You write &lt;code&gt;.lua&lt;/code&gt; files, reference them in a &lt;code&gt;.toc&lt;/code&gt; manifest, drop the folder into &lt;code&gt;Interface/AddOns/&lt;/code&gt;, and the game loads it.&lt;/p&gt;
&lt;p&gt;Persistence works through &lt;code&gt;SavedVariables&lt;/code&gt;. You declare a table name in the &lt;code&gt;.toc&lt;/code&gt; file and the game automatically serializes it to disk when you log out. It’s a simple system that mostly stays out of your way.&lt;/p&gt;
&lt;p&gt;The Classic Anniversary realm targets interface version &lt;code&gt;11500&lt;/code&gt;. A lot of the API is different from Retail and even slightly different across Classic versions, which turned out to matter when it came to actually sending the invite.&lt;/p&gt;
&lt;h2 id=&quot;the-pattern-matching-problem&quot;&gt;The pattern matching problem&lt;/h2&gt;
&lt;p&gt;The first version used a simple &lt;code&gt;string.find(msg, &amp;quot;port&amp;quot;)&lt;/code&gt; check. This works until someone types “I imported that mount from the auction house” in General chat. So it needed real word boundaries.&lt;/p&gt;
&lt;p&gt;Lua’s pattern language doesn’t have &lt;code&gt;\b&lt;/code&gt;. It has &lt;code&gt;%f[%a]&lt;/code&gt;, the “frontier pattern,” which matches a position where the previous character is not in the set and the next one is. It’s how you do word boundaries in Lua:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;lua&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; MsgHasPortalRequest&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF;font-style:italic&quot;&gt;msg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; type&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;msg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) ~= &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;string&amp;quot; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; = &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;msg&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;lower&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; hasWTB&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; = &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;find&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]wtb%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) ~= &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;nil&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; hasLF&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  = &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;find&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]lf%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)  ~= &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;nil&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; hasPort&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;find&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]ports?%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)   ~= &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;nil&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;or&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;find&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]portals?%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) ~= &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;nil&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;hasWTB&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; or&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; hasLF&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;and&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; hasPort&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;%f[%a]&lt;/code&gt; before a word means “position where the previous char is not a letter.” &lt;code&gt;%f[%A]&lt;/code&gt; after means “position where the next char is not a letter.” Combined, they give you real word boundaries. “airport” doesn’t match because &lt;code&gt;%f[%a]port&lt;/code&gt; requires a non-letter before “port,” and there’s an “r” there.&lt;/p&gt;
&lt;p&gt;The invite logic also checks whether you can actually invite before doing anything. You can’t invite someone if you’re not the raid leader, if the party is full, or if the addon is disabled:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;lua&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; CanSendInvite&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; IsInRaid&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; IsInRaid&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; UnitIsGroupLeader&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; UnitIsGroupLeader&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;player&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; UnitIsGroupAssistant&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; UnitIsGroupAssistant&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;player&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;not raid leader/assistant&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; IsInGroup&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; IsInGroup&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; UnitIsGroupLeader&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; UnitIsGroupLeader&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;player&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; GetNumGroupMembers&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; GetNumGroupMembers&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &amp;gt;= &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;5&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;party full&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;      return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;not party leader&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The API compatibility shim for actually sending the invite was also necessary. Classic exposes &lt;code&gt;InviteUnit&lt;/code&gt; as a global; some versions only have &lt;code&gt;C_PartyInfo.InviteUnit&lt;/code&gt;. The addon tries both:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;lua&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; SendInviteByName&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF;font-style:italic&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; type&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;InviteUnit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) == &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;function&amp;quot; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;    InviteUnit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;InviteUnit&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; C_PartyInfo&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; type&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;C_PartyInfo&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.InviteUnit) == &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;function&amp;quot; &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    C_PartyInfo&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;InviteUnit&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;C_PartyInfo.InviteUnit&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;no invite API&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;end&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;trade-chat-was-a-disaster&quot;&gt;Trade chat was a disaster&lt;/h2&gt;
&lt;p&gt;The addon originally had Trade (&lt;code&gt;/2&lt;/code&gt;) listening enabled by default. This lasted about ten minutes before it became clear that was a mistake.&lt;/p&gt;
&lt;p&gt;Trade chat is not General chat. People post long trade offers with multiple city names in them: “WTB port or any TBC mats, currently in TB need to get to UC for raid.” The basic portal detector would happily fire an invite on that. The person isn’t buying a portal from you; they’re trading mats, they mentioned a city in passing, and now they have an unexpected group invite from a stranger.&lt;/p&gt;
&lt;p&gt;The fix was a separate, stricter filter for Trade channel only. It rejects messages where someone specifies a source city that isn’t Orgrimmar (because if you’re already in Orgrimmar, you need a portal out; if you’re in Thunder Bluff, you need something else entirely). It also catches implicit city pairs: “tb to uc” reads as “I’m in Thunder Bluff going to Undercity,” which means I can’t help.&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;lua&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;-- Reject &amp;quot;from &amp;lt;other city&amp;gt;&amp;quot; patterns in Trade.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; fromOtherCity&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  fromHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]uc%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;or&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fromHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]undercity%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;or&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  fromHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]tb%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;or&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fromHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]thunderbluff%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;or&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  fromHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]shat%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;or&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fromHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]shattrath%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; fromOtherCity&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; and&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; not&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; fromOrg&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;end&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;-- Also reject implicit &amp;quot;tb to uc&amp;quot; patterns.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; srcOtherCityTo&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  srcBeforeToHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]tb%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;or&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; srcBeforeToHas&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;%f[%a]undercity%f[%A]&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  -- ... etc.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; srcOtherCityTo&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; then&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; end&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Trade listening is still there, but it defaults to off. Most mages selling portals are standing in Orgrimmar anyway, and General chat is noisy enough to catch real buyers.&lt;/p&gt;
&lt;h2 id=&quot;the-minimap-button&quot;&gt;The minimap button&lt;/h2&gt;
&lt;p&gt;This was the hardest part. Not conceptually hard, just fiddly in a way that took longer than expected.&lt;/p&gt;
&lt;p&gt;WoW addons build UI by creating frames and attaching textures to them. A minimap button sounds simple: create a button, parent it to the Minimap frame, place it on the edge. In practice, there are four separate textures (background fill, icon, border ring, hover highlight), each needs to be independently sized and offset, and the whole thing needs to stay draggable while remaining clamped to the screen edge.&lt;/p&gt;
&lt;p&gt;The button position is stored as an angle in SavedVariables so it persists between sessions. On load it converts the angle back to &lt;code&gt;x/y&lt;/code&gt; coordinates relative to the minimap center:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;lua&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; rad&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    = &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;angle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; * &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;math.pi&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; / &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;180&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; radius&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; = &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;math.min&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;mmw&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;mmh&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) / &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; + &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;10&lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  -- just outside the minimap edge&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; = &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;math.cos&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rad&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) * &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;radius&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;local&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; = &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;math.sin&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rad&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) * &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;radius&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;minimapButton&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;SetPoint&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;CENTER&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;Minimap&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;CENTER&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;x&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;y&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Getting the offsets right so the icon, background, and border ring all looked centered took several iterations. Each texture has its own pixel offset because the WoW border texture isn’t square and doesn’t align with the button frame the way you’d expect.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;It works. I use it when I’m farming portals on my mage. I open the game, turn on the addon, stand in Orgrimmar, and let it run. If someone asks for a port in General or Yell, I get an invite attempt automatically with a chat message telling me who and why.&lt;/p&gt;
&lt;p&gt;The code is on &lt;a href=&quot;https://github.com/caden311/mage-portals&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;GitHub&lt;/a&gt;. It’s not on CurseForge yet. It’s a single Lua file; if you play a mage on Classic Anniversary you can drop it in your AddOns folder and it should just work.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Design the config schema first.&lt;/strong&gt; The &lt;code&gt;SavedVariables&lt;/code&gt; table grew as I added features: channel toggles, throttle duration, minimap position, whisper behavior, water invites. Each one made sense when I added it, but the result is a flat table with a dozen unrelated keys. A more intentional structure upfront would have been cleaner to maintain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write tests before patterns.&lt;/strong&gt; The pattern matching functions are the most important logic in the addon and the most fragile. I tested them manually by typing strings into the chat box. That worked, but a standalone test file with a table of expected matches and non-matches would have caught regressions much faster and made the Trade channel filter much less painful to get right.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Publish it.&lt;/strong&gt; The whole point of automation is that other people have the same problem. Other mages are also missing portal requests. The friction to put this on CurseForge is low; I just haven’t done it yet.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;more side projects&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/&quot;&gt;more devlogs&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>lua</category><category>world-of-warcraft</category><category>wow-addon</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Built a Chrome Extension That Tells Jokes (4,600 People Use It)</title><link>https://travelvient.com/blog/building-joke-of-the-day/</link><guid isPermaLink="true">https://travelvient.com/blog/building-joke-of-the-day/</guid><description>How a simple joke extension became a TypeScript learning project, a monetization experiment, and somehow one of the more successful things I&apos;ve shipped.</description><pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted a Chrome extension that would tell me a joke when I opened a new tab or clicked the icon. Something clean, fast, and actually funny. I looked around. The ones I found were slow, ugly, or pulling from joke APIs with no content filtering. So I built my own.&lt;/p&gt;
&lt;p&gt;That was the whole origin story. No grand vision. No market research. I wanted a thing, the existing things weren’t good, so I made the thing.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;Joke of the Day is a Chrome extension (Manifest V3) that serves you one joke per day. Click the icon, you get a joke: a setup and a punchline. Free users get a new joke every 12 hours, plus one free skip per cycle if the joke isn’t landing. Premium users ($3.99, one-time) get unlimited skips.&lt;/p&gt;
&lt;p&gt;It has 4.6k installs and a 4.6 rating on the Chrome Web Store. I did zero marketing. It just… grew.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;The first version was plain JavaScript with a build script held together with string. It worked but was annoying to extend. When I wanted to add the premium tier and a proper joke engine, I rewrote it as a learning exercise: TypeScript 5.x, Vite with the CRXJS plugin, strict mode on.&lt;/p&gt;
&lt;p&gt;CRXJS is worth calling out specifically. It handles the annoying parts of Chrome extension development: hot module reload during development, manifest validation, bundling the service worker and popup as separate entry points. Without it, MV3 development involves a lot of manual reloading and guessing why the service worker died.&lt;/p&gt;
&lt;p&gt;For payments I used &lt;a href=&quot;https://extensionpay.com&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;ExtensionPay&lt;/a&gt;. No Stripe integration to maintain, no webhook server, no subscription management UI. The SDK is a single import. Under MV3 you have to call &lt;code&gt;startBackground()&lt;/code&gt; in the service worker as well as the popup, because the service worker can be killed at any time and needs to reinitialize payment state when it wakes up:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// service-worker.ts&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;chrome&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;runtime&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;onInstalled&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;addListener&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; () &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;EXTENSIONPAY_ID&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;    ExtPay&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;EXTENSIONPAY_ID&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;startBackground&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;  chrome&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;alarms&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;create&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ALARM_NAME&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;periodInMinutes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  await&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; storage&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getUserState&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// Also runs on every service worker startup, not just install.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;EXTENSIONPAY_ID&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  ExtPay&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;EXTENSIONPAY_ID&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;startBackground&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That second &lt;code&gt;startBackground()&lt;/code&gt; call outside the listener is not a bug. Service workers under MV3 are ephemeral: they start, do their job, and shut down. The &lt;code&gt;onInstalled&lt;/code&gt; handler only fires once. Every other time the worker wakes up, you need that top-level call.&lt;/p&gt;
&lt;h2 id=&quot;the-joke-engine&quot;&gt;The joke engine&lt;/h2&gt;
&lt;p&gt;This is the part I spent the most time on, and the part I’m most happy with.&lt;/p&gt;
&lt;p&gt;The extension has three tiers of jokes, served in alternating order. First it works through 500 curated monthly jokes (seasonal, tied to the current month). Then it alternates with a general pool. When both curated pools are exhausted, it falls through to an infinite generator.&lt;/p&gt;
&lt;p&gt;The alternating logic sits in &lt;code&gt;getCurrentJoke()&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; getCurrentJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(): &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;Joke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; totalSeen&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentMonthJoke&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; preferMonthly&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; totalSeen&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; %&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; hasMonthly&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentMonthJoke&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; monthlyJokes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; hasGeneral&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;  =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentJoke&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;     &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; generalJokes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;preferMonthly&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;hasMonthly&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; monthlyJokes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentMonthJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;hasGeneral&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)  &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; generalJokes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  } &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;hasGeneral&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)  &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; generalJokes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;hasMonthly&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)  &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; monthlyJokes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentMonthJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // Both curated pools exhausted, use the generator.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; this&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;getGeneratedJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even if someone used the extension every single day for years, they’d never hit a wall. The fallthrough to the generator is seamless.&lt;/p&gt;
&lt;p&gt;The generator itself is template-based. It has a set of joke structures (templates with named slots) and word pools keyed by slot name. Each month has overrides that weight the pools toward seasonal vocabulary:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; MONTH_OVERRIDES&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Record&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;number&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Partial&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Pools&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;&amp;gt; &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;  9&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// October&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    holidayThing&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;a ghost&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;a witch&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;a pumpkin&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    seasonThing&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:  [&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;pumpkins&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;costumes&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;candy&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;spooky sounds&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    punNoun&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:      [&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;broommates&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;a boo-tiful idea&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;a fright delight&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    punchline&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;:    [&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;was having a spook-tacular time&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;didn&amp;#39;t want to be a scaredy-cat&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // ... one entry per month&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;};&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;mergePools&lt;/code&gt; function combines base and override by prepending seasonal words, so they appear more often than their base counterparts without completely replacing them. And the &lt;code&gt;fill()&lt;/code&gt; function slots words into templates using a regex replace:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; fill&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;str&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;pools&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Pools&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; str&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;replace&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;\{&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;(\w&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;\}&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;_&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;key&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;keyof&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Pools&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; pick&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pools&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;key&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One line. Takes &lt;code&gt;&amp;quot;Why did the {thing} {verb}?&amp;quot;&lt;/code&gt; and turns it into &lt;code&gt;&amp;quot;Why did the snowman start a band?&amp;quot;&lt;/code&gt;. The deduplication uses a normalized key so near-identical jokes don’t slip through on different runs:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; normalizeKey&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;j&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Joke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; `&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;j&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;month&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ??&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;x&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;j&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;question&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toLowerCase&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;j&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;answer&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;trim&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toLowerCase&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-badge-problem&quot;&gt;The badge problem&lt;/h2&gt;
&lt;p&gt;MV3 killed persistent background pages. That was fine for most of what I needed, but it created a specific problem: I wanted to show a “NEW” badge on the extension icon when a joke became available. Under MV2, you’d just have a background page listening to a timer. Under MV3, the service worker is gone the moment it goes idle.&lt;/p&gt;
&lt;p&gt;The fix is Chrome’s Alarms API. You register an alarm, and Chrome will wake your service worker on a schedule even if it’s been shut down:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;chrome&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;alarms&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;create&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;ALARM_NAME&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;periodInMinutes&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;chrome&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;alarms&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;onAlarm&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;addListener&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;alarm&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;alarm&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ===&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; ALARM_NAME&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    await&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; checkAndNotifyNewJoke&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every 30 minutes Chrome wakes the worker, it checks if a new joke is available, and either sets the badge or clears it. The worker then shuts down again. The key insight: you’re not keeping the worker alive. You’re scheduling wake-ups.&lt;/p&gt;
&lt;h2 id=&quot;what-surprised-me&quot;&gt;What surprised me&lt;/h2&gt;
&lt;p&gt;4,600 installs with no marketing. No Product Hunt launch, no Reddit post, no Twitter thread. Just the Chrome Web Store listing and an SEO-friendly title.&lt;/p&gt;
&lt;p&gt;I don’t fully understand it. My best guess: the search term “joke of the day extension” has low competition and the listing copy is specific enough to rank. Whatever the reason, it’s the most passive thing I’ve ever shipped.&lt;/p&gt;
&lt;p&gt;The 4.6 rating is actually what I’m most proud of. Extensions are hard to rate well because users install them, forget about them, and only bother to review when something goes wrong. A 4.6 on ~50 reviews means people actively liked it enough to say so.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;It’s live at the &lt;a href=&quot;https://chromewebstore.google.com/detail/joke-of-the-day/knbheoggdkhaaakmfgdlephniiicdmbd&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;Chrome Web Store&lt;/a&gt;. The premium tier is $3.99 one-time, which is probably too cheap, but it converts and I don’t want to think about billing support.&lt;/p&gt;
&lt;p&gt;The codebase is TypeScript throughout, which was the whole point of the rewrite. Strict mode caught several bugs that would have shipped silently in the original JS version.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Start with TypeScript.&lt;/strong&gt; The rewrite wasn’t painful but it was unnecessary. Strict TypeScript from the start would have caught the same bugs earlier.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pick a more defensible content niche.&lt;/strong&gt; “Jokes” is broad. A version scoped to a specific style (dry humor, dad jokes only, one-liners) would be easier to market and would attract more loyal users.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add telemetry earlier.&lt;/strong&gt; I have no idea which jokes users see most, which ones get skipped, or how many people actually exhaust the curated pool. The storage layer exists; adding basic anonymous analytics would have taken a day and would have made the joke generator much easier to tune.&lt;/p&gt;
&lt;p&gt;If you’re thinking about building a Chrome extension, the tooling has gotten genuinely good. TypeScript + Vite + CRXJS is a fast setup and MV3’s constraints are manageable once you internalize the service worker lifecycle. Start small. Something you’d use yourself is the right place to begin.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/joke-of-the-day/&quot;&gt;Joke of the Day&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;more side projects&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>chrome-extension</category><category>typescript</category><category>vite</category><category>manifest-v3</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author><enclosure url="https://travelvient.com/_astro/joke-of-the-day.dvantn3d.png" length="0" type="image/jpeg"/></item><item><title>I Shipped a Client Site in an Afternoon Using an AI Builder</title><link>https://travelvient.com/blog/building-griffin-renovation/</link><guid isPermaLink="true">https://travelvient.com/blog/building-griffin-renovation/</guid><description>A renovation company needed a website fast. I used LandingSite.ai to go from nothing to live in a few hours, and here&apos;s what actually worked.</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A referral came in: a renovation company needed a website up as soon as possible. No existing site, no branding assets beyond a business name, and a client who wanted something live before their next job started. The kind of timeline where you don’t have time to spin up a custom Astro build, write copy from scratch, and figure out a contact form.&lt;/p&gt;
&lt;p&gt;So I tried something I hadn’t used before. &lt;a href=&quot;https://www.landingsite.ai/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;LandingSite.ai&lt;/a&gt; is an AI website builder that generates a complete site from a business description. You type a few sentences about the company, it produces a full multi-section site with copy, a logo, layout, and hosted infrastructure in about five minutes.&lt;/p&gt;
&lt;p&gt;I was skeptical. I’ve seen too many AI tools that produce something impressive in the demo and underwhelming in practice. This one surprised me.&lt;/p&gt;
&lt;h2 id=&quot;what-the-site-needed-to-do&quot;&gt;What the site needed to do&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.griffinrenovation.com/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;Griffin Renovation&lt;/a&gt; is a renovation company with 32 years of experience. They do drywall, painting, framing, trim work, and full project management. The site needed to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Communicate credibility fast (the 32 years matters)&lt;/li&gt;
&lt;li&gt;Show past work (gallery with before/after photos)&lt;/li&gt;
&lt;li&gt;Make it easy to reach them (phone, email, contact form)&lt;/li&gt;
&lt;li&gt;Work on mobile without me hand-tuning a single media query&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nothing technically exotic. But “nothing technically exotic” still takes days to build well from scratch if you’re writing the code yourself and also writing the copy and also sourcing photos.&lt;/p&gt;
&lt;h2 id=&quot;how-landingsite-actually-works&quot;&gt;How LandingSite actually works&lt;/h2&gt;
&lt;p&gt;You describe the business in plain language. The AI generates a full website: hero section, about section, services, gallery, testimonials, contact form, and a logo. It hosts it, handles SSL, and gives you a subdomain immediately. You can connect a custom domain later.&lt;/p&gt;
&lt;p&gt;The generated site uses Tailwind CSS and produces clean semantic markup. The content structure was genuinely solid for a trade services company. It pulled together the right sections in the right order without me specifying anything beyond the business type.&lt;/p&gt;
&lt;p&gt;The piece I didn’t expect to work as well: the contact form. It was functional out of the box, routed to the client’s email, and handled spam filtering without any configuration on my end.&lt;/p&gt;
&lt;h2 id=&quot;what-needed-fixing&quot;&gt;What needed fixing&lt;/h2&gt;
&lt;p&gt;The copy it generated was the main problem. The AI gave Griffin Renovation a voice that felt like a generic contractor, heavy on phrases like “transforming your living spaces” and “tailored experiences.” Those aren’t wrong, but they weren’t specific enough to stand out. The 32 years of experience was there but buried.&lt;/p&gt;
&lt;p&gt;I went through every section and rewrote the copy to be more direct and specific: leading with what the company actually does, using the client’s language, making the experience claim prominent rather than parenthetical. The AI had good structure; it just needed a human voice.&lt;/p&gt;
&lt;p&gt;The layout also needed work. The default spacing was conservative in a way that made the site feel cramped on desktop. LandingSite has an “advanced edit” mode that gives you access to the CSS directly. I used it to adjust padding on several sections, fix the gallery grid proportions, and get the hero section to breathe properly. It’s a real CSS editor, not a drag-and-drop toy, which meant I could make the specific changes I needed without fighting an abstraction layer.&lt;/p&gt;
&lt;h2 id=&quot;the-part-id-skip-next-time&quot;&gt;The part I’d skip next time&lt;/h2&gt;
&lt;p&gt;The spacing issues were all fixable in the CSS editor, but a few layout problems took longer than they should have because I was working within the AI-generated structure rather than just writing the HTML myself. There’s a specific kind of frustration that comes from trying to override a layout you didn’t design: you fix one thing, something else shifts, you fix that.&lt;/p&gt;
&lt;p&gt;For a quick client site, LandingSite was the right call. But anything with specific layout requirements would be faster to build from scratch. The AI builder saves you time on boilerplate and copy scaffolding. It costs you time when the generated structure doesn’t match what you actually need.&lt;/p&gt;
&lt;h2 id=&quot;where-it-is-now&quot;&gt;Where it is now&lt;/h2&gt;
&lt;p&gt;The site is live at &lt;a href=&quot;https://www.griffinrenovation.com/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;griffinrenovation.com&lt;/a&gt;. It has the gallery, the contact form, the testimonials, the full services breakdown. The client was happy with the turnaround.&lt;/p&gt;
&lt;p&gt;LandingSite charges a monthly fee for hosting and the continued AI editing. For a small business site that doesn’t change often, that’s a reasonable tradeoff against the cost of self-hosting and maintaining your own infrastructure. The client isn’t going to be pushing code; they need to be able to ask the AI to update their phone number or add a new photo.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Get a content brief before generating anything.&lt;/strong&gt; The AI copy problems came from vague input. If I’d collected the client’s actual service descriptions, their differentiators, and the exact phrases they use with customers before starting, the first pass would have been much closer to the final version.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start with custom code for anything layout-sensitive.&lt;/strong&gt; The gallery and hero section were the two places that needed the most CSS work. Both are common enough components that I could have written them in a few hours, and I’d have had full control. The AI builder earns its time savings on forms, copy scaffolding, and the parts that are genuinely tedious to build. The layout stuff it generates is fine for a standard site but fragile when requirements are specific.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Set expectations about the AI editing model early.&lt;/strong&gt; The client will probably want to make changes eventually. The LandingSite chat interface is intuitive, but “tell the AI what you want” is a different mental model from a traditional CMS. Walking the client through that before handing off would prevent confusion later.&lt;/p&gt;
&lt;p&gt;The overall verdict: for a client who needs something live quickly and doesn’t have strong opinions about layout, LandingSite is a legitimate option. It’s not a replacement for a real build, but “real build” isn’t always the right tool for the job.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/griffin-renovation/&quot;&gt;Griffin Renovation&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/blog/moving-griffin-renovation-off-landingsite/&quot;&gt;why I moved it off LandingSite&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>no-code</category><category>ai-tools</category><category>web-design</category><category>client-work</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item><item><title>I Built Smoke or Fire: A React Native Card Game</title><link>https://travelvient.com/blog/building-smoke-or-fire/</link><guid isPermaLink="true">https://travelvient.com/blog/building-smoke-or-fire/</guid><description>How a party card game became a React Native + Firebase multiplayer app, the Firebase array normalization problem that almost broke multiplayer.</description><pubDate>Thu, 20 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;it-started-at-a-party&quot;&gt;It started at a party&lt;/h2&gt;
&lt;p&gt;Someone pulled out a deck of cards and announced we were playing Smoke or Fire. You probably know it. You guess whether the next card is red or black (smoke or fire), then higher or lower, then inside or outside your first two cards, then the suit. Get it right and someone drinks. Get it wrong and you drink. There’s a pyramid at the end where everyone’s cards come back to haunt them. Simple, chaotic, and genuinely fun.&lt;/p&gt;
&lt;p&gt;The problem: you always need a physical deck of cards. Someone has to shuffle, someone has to deal, someone has to remember whose turn it is. Half the time the game falls apart before the pyramid because nobody can keep track.&lt;/p&gt;
&lt;p&gt;I had been wanting to learn &lt;a href=&quot;https://reactnative.dev&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;React Native&lt;/a&gt; for a while. Not just read about it, actually build something real with it. Smoke or Fire seemed perfect. It’s a game I knew well enough that I wasn’t figuring out the rules and the code at the same time, and the UI is essentially just cards and buttons. It felt scoped.&lt;/p&gt;
&lt;p&gt;So I built it. The full source is on &lt;a href=&quot;https://github.com/caden311/smoke-or-fire&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-game-does&quot;&gt;What the game does&lt;/h2&gt;
&lt;p&gt;The app has two phases.&lt;/p&gt;
&lt;p&gt;The first is four sequential rounds. Every player takes a turn guessing before their card is flipped. Round one: red or black. Round two: higher or lower than your previous card. Round three: inside or outside your first two cards. Round four: name the suit. Correct means you give out drinks. Wrong means you take them.&lt;/p&gt;
&lt;p&gt;After all four rounds, the pyramid. Nine cards in a diamond pattern, revealed one at a time. Each revealed card matches against every card every player drew across rounds one through four. Match a card in row one and you give a drink. Row two and you take one. The drink amounts scale toward the center. By the last card, things get chaotic.&lt;/p&gt;
&lt;p&gt;Two ways to play: pass-and-play for couch sessions on a single phone, or real-time multiplayer where everyone joins with a four-letter room code. The room codes exclude the letters I and O so nobody types a zero when they mean an O:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; generateRoomCode&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; letters&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;ABCDEFGHJKLMNPQRSTUVWXYZ&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;// Exclude I and O to avoid confusion&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; code&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 4&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;i&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    code&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +=&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; letters&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;charAt&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;floor&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Math&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;random&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; letters&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; code&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Small thing. Noticed it during testing when someone kept entering the wrong code.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://reactnative.dev&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;React Native 0.81&lt;/a&gt; + &lt;a href=&quot;https://expo.dev&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;Expo SDK 54&lt;/a&gt; because cross-platform was the goal from day one. iOS, Android, and web from one codebase, with Expo handling the native configuration that used to eat entire weekends.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.typescriptlang.org&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;TypeScript&lt;/a&gt; with strict mode throughout. For a multiplayer game with this much state, the type safety paid for itself within the first few days.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://firebase.google.com/docs/database&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;Firebase Realtime Database&lt;/a&gt; for multiplayer sync. More on why this caused problems in a minute.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.swmansion.com/react-native-reanimated/&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;react-native-reanimated v4&lt;/a&gt; for the card flip animation. The 3D perspective flip on every card reveal is the single most satisfying part of the app. Reanimated’s worklet system runs on the native thread, which means the animation stays smooth even when Firebase is doing something slow in the background. The full hook is about 40 lines:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt; function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; useCardAnimation&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; rotation&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; useSharedValue&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; isFlipped&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; useSharedValue&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;false&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; flip&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; useCallback&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(() &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    rotation&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; withTiming&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;180&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      duration&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;600&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;      easing&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Easing&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;inOut&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Easing&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;ease&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;    isFlipped&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }, [&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rotation&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;isFlipped&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; frontAnimatedStyle&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; useAnimatedStyle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(() &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    transform&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [{ &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;perspective&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1000&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rotateY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;rotation&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; 180&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;deg`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    backfaceVisibility&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;hidden&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    position&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;absolute&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; backAnimatedStyle&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; useAnimatedStyle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(() &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; ({&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    transform&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: [{ &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;perspective&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt;1000&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }, { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rotateY&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;`&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;${&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;rotation&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;value&lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;deg`&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; }],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    backfaceVisibility&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;hidden&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    position&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;absolute&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;flip&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;reset&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;frontAnimatedStyle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;backAnimatedStyle&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For state management: Context and &lt;code&gt;useReducer&lt;/code&gt;, no Redux or Zustand. The game logic lives in a single &lt;code&gt;gameReducer&lt;/code&gt; function with typed actions. Making that reducer pure and stateless was the right call. The same function drives both local games and Firebase-synced multiplayer, which meant I only had to get the game logic right once.&lt;/p&gt;
&lt;p&gt;Google Mobile Ads for monetization. &lt;a href=&quot;https://expo.dev/eas&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;EAS&lt;/a&gt; (Expo Application Services) for building and distributing.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-firebase-multiplayer-sync&quot;&gt;The hard part: Firebase multiplayer sync&lt;/h2&gt;
&lt;p&gt;Multiplayer was harder than I expected.&lt;/p&gt;
&lt;p&gt;The architecture works like this: one player is the host. When the host takes an action, the reducer runs locally and the result gets written to Firebase. When a non-host player takes an action, they write to a &lt;code&gt;pendingAction&lt;/code&gt; path instead. The host sees that, runs the reducer, and writes the result back. Everyone subscribes to the same game state and re-renders when it changes.&lt;/p&gt;
&lt;p&gt;Simple in theory. Firebase had other ideas.&lt;/p&gt;
&lt;p&gt;Firebase Realtime Database will silently mangle your data. Empty arrays become &lt;code&gt;undefined&lt;/code&gt;. Arrays with items come back as objects with numeric keys (&lt;code&gt;{ 0: card, 1: card }&lt;/code&gt;). Fields that were never set come back as &lt;code&gt;null&lt;/code&gt;. After the first multiplayer bug, where player cards were disappearing between rounds, I added a &lt;code&gt;normalizeGameState()&lt;/code&gt; function that runs on every single read from Firebase:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; normalizeGameState&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;GameState&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;GameState&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; toArray&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[] | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Record&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;&amp;gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; | &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;undefined&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;): &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;T&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[] &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;Array&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;isArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;)) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; val&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;    // Firebase turned your array into { 0: item, 1: item }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; Object&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;values&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;val&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // Ensure we have one playerCards array per player, even if Firebase&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // deleted empty arrays for players who haven&amp;#39;t drawn yet&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; players&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;players&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; rawPlayerCards&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt; toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;playerCards&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; normalizedPlayerCards&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt; players&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;((&lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;_&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;index&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;=&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;    toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;rawPlayerCards&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;index&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;    ...&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    currentCard&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentCard&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ??&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    currentGuess&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;currentGuess&lt;/span&gt;&lt;span style=&quot;color:#56B6C2&quot;&gt; ??&lt;/span&gt;&lt;span style=&quot;color:#D19A66&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    players&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;players&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    deck&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;deck&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    playerCards&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;normalizedPlayerCards&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    pyramidCards&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pyramidCards&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    pyramidRevealed&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pyramidRevealed&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    pendingDrinkAssignments&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pendingDrinkAssignments&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;    pyramidPendingAssigners&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;toArray&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E5C07B&quot;&gt;state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt;pyramidPendingAssigners&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This fixed the disappearing cards. It also introduced a related pattern in the reducer itself: using loose equality (&lt;code&gt;!=&lt;/code&gt;) to guard against both &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt; from Firebase, since you can’t always control which one you get:&lt;/p&gt;
&lt;pre class=&quot;astro-code one-dark-pro&quot; style=&quot;background-color:#282c34;color:#abb2bf;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;case&lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt; &amp;quot;MAKE_GUESS&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // Use != (loose) to catch both null AND undefined from Firebase&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#61AFEF&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; (state.phase !== &lt;/span&gt;&lt;span style=&quot;color:#98C379&quot;&gt;&amp;quot;playing&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt; || state.currentCard != &lt;/span&gt;&lt;span style=&quot;color:#E06C75;font-style:italic&quot;&gt;null&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#C678DD&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E06C75&quot;&gt; state&lt;/span&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#7F848E;font-style:italic&quot;&gt;  // ... process the guess&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#ABB2BF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The other piece that took real time was the pending action pattern. The host needs to process actions from non-host players in order and exactly once. I ended up timestamping each pending action and tracking the last processed timestamp in a ref to prevent double-processing. Overkill for a card game, but multiplayer drink assignments cannot afford to double-count.&lt;/p&gt;
&lt;h2 id=&quot;the-app-store-saga&quot;&gt;The App Store saga&lt;/h2&gt;
&lt;p&gt;Submitted. Rejected. Guideline 1.4.3: apps that encourage or enable excessive consumption of alcohol.&lt;/p&gt;
&lt;p&gt;Fair. I updated the metadata. Removed the word “drinking.” Resubmitted.&lt;/p&gt;
&lt;p&gt;Rejected again.&lt;/p&gt;
&lt;p&gt;Reframed the entire listing as a “party card game.” Positioned the drink mechanic as optional social scoring. Resubmitted.&lt;/p&gt;
&lt;p&gt;Rejected again.&lt;/p&gt;
&lt;p&gt;Removed all references to alcohol from screenshots and description. The listing described it as a card guessing game where correct answers mean you give points to other players. Nothing about drinking anywhere.&lt;/p&gt;
&lt;p&gt;Rejected again.&lt;/p&gt;
&lt;p&gt;Apple’s position seems to be: if the game mechanic is drinking, it qualifies under 1.4.3 regardless of how the metadata describes it. The game is the game.&lt;/p&gt;
&lt;p&gt;At some point I had to decide whether to keep fighting over a free app. I didn’t. I moved on.&lt;/p&gt;
&lt;h2 id=&quot;where-it-lives-now&quot;&gt;Where it lives now&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://smokeorfire.travelvient.com&quot;&gt;smokeorfire.travelvient.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Expo Web support meant the app already ran in a browser. The card flip animations work in Chrome. Multiplayer works. No install required. You text someone the link and you’re playing.&lt;/p&gt;
&lt;p&gt;It’s slightly bittersweet that it never made it into the App Store, but the web version is genuinely fine. More than fine, actually. Anyone with a browser can play it, which is more people than would ever find it in the App Store anyway.&lt;/p&gt;
&lt;p&gt;Source code is on &lt;a href=&quot;https://github.com/caden311/smoke-or-fire&quot; rel=&quot;nofollow noopener noreferrer&quot; target=&quot;_blank&quot;&gt;GitHub&lt;/a&gt; if you want to dig into the multiplayer architecture or use the Firebase normalization pattern.&lt;/p&gt;
&lt;h2 id=&quot;what-id-do-differently&quot;&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Start with web as the primary target&lt;/strong&gt; if there’s any chance the App Store might reject your app. I built a full React Native app and discovered the main distribution channel was closed to me. The web fallback worked, but it was a fallback, not a plan.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use Firestore instead of Realtime Database.&lt;/strong&gt; Firebase Realtime Database works, but the normalization overhead was significant. Firestore returns data in a format that doesn’t mangle your arrays, and the query model is better for structured game state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write tests for the reducer.&lt;/strong&gt; The game state logic got complex, especially the pyramid drink assignment across multiple players in real time. I tested it manually as I built it, which worked until it didn’t. A test suite for &lt;code&gt;gameReducer&lt;/code&gt; would have caught several multiplayer bugs before they reached production.&lt;/p&gt;
&lt;p&gt;The game is out there. People play it. That part worked.&lt;/p&gt;
&lt;p&gt;Related: &lt;a href=&quot;https://travelvient.com/projects/smoke-or-fire/&quot;&gt;Smoke or Fire&lt;/a&gt; and &lt;a href=&quot;https://travelvient.com/projects/&quot;&gt;more side projects&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>react-native</category><category>expo</category><category>typescript</category><category>firebase</category><category>devlog</category><author>cadendeveloper@gmail.com (Travel Vient)</author></item></channel></rss>