<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  
  <title>Keenan Pereira · Problems &amp; solutions</title>
  <subtitle>Problem-vs-solution write-ups from shipping websites, marketing data pipelines, GIS tools and AI agents.</subtitle>
  <link href="https://www.keenan-pereira.in/feed.xml" rel="self" />
  <link href="https://www.keenan-pereira.in/" />
  <updated>2026-10-27T18:00:00Z</updated>
  <id>https://www.keenan-pereira.in/</id>
  <author>
    <name>Keenan Pereira</name>
  </author>
  <entry>
    <title>From 3.3 MB to 180 KB, the image diet behind a fast landing page</title>
    <link href="https://www.keenan-pereira.in/blog/landing-page-image-diet/" />
    <updated>2026-10-27T06:20:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/landing-page-image-diet/</id>
    <summary>A premium product needs big, beautiful photos, and a paid-traffic page needs to load on a phone in a basement car park. A small Node pipeline that gets you both.</summary>
    <content type="html">&lt;p&gt;Luxury sells on photography. A premium home-automation brand can’t use small, muddy images. But people arrive at an ads landing page on phones, often on patchy mobile data, and every extra second on a slow load loses some of them.&lt;/p&gt;
&lt;p&gt;The question wasn’t “big images or fast page”. It was how to serve images that look big without weighing much.&lt;/p&gt;
&lt;h2 id=&quot;where-the-weight-was&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-image-diet/#where-the-weight-was&quot;&gt;&lt;span&gt;Where the weight was&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The source folder was what you’d expect from a design team: camera exports and renders of up to &lt;strong&gt;16–17 MB each&lt;/strong&gt;. After a first round of compression the page still pulled about &lt;strong&gt;3.3 MB&lt;/strong&gt; of images:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image set&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hero&lt;/td&gt;
&lt;td&gt;1,772 KB&lt;/td&gt;
&lt;td&gt;35 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two experience-centre photos&lt;/td&gt;
&lt;td&gt;429 KB&lt;/td&gt;
&lt;td&gt;53 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five project photos&lt;/td&gt;
&lt;td&gt;393 KB&lt;/td&gt;
&lt;td&gt;73 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Six product cut-outs (PNG)&lt;/td&gt;
&lt;td&gt;701 KB&lt;/td&gt;
&lt;td&gt;22 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;step-1-convert&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-image-diet/#step-1-convert&quot;&gt;&lt;span&gt;Step 1: convert&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;convert-webp.js&lt;/code&gt; runs every source image through &lt;a href=&quot;https://sharp.pixelplumbing.com/&quot;&gt;sharp&lt;/a&gt; and writes WebP at quality 82. At that setting I couldn’t see a difference on a phone, and the files are a fraction of the size. Product cut-outs that had been huge transparent PNGs gained the most.&lt;/p&gt;
&lt;h2 id=&quot;step-2-resize-to-the-layout-not-the-camera&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-image-diet/#step-2-resize-to-the-layout-not-the-camera&quot;&gt;&lt;span&gt;Step 2: resize to the layout, not the camera&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Most of the waste wasn’t compression; it was &lt;strong&gt;pixels nobody sees&lt;/strong&gt;. A 4,000 px photo shown in a 600 px card makes the phone download and then throw away almost everything. &lt;code&gt;resize-for-cdn.js&lt;/code&gt; crops and resizes each image to the exact size it displays at, so Lighthouse reports no wasted bytes.&lt;/p&gt;
&lt;h2 id=&quot;step-3-let-the-browser-help&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-image-diet/#step-3-let-the-browser-help&quot;&gt;&lt;span&gt;Step 3: let the browser help&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Every image goes through &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; with WebP first and JPG or PNG as the fallback.&lt;/li&gt;
&lt;li&gt;Every image has explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;, so nothing jumps as it loads.&lt;/li&gt;
&lt;li&gt;Everything below the fold is &lt;code&gt;loading=&amp;quot;lazy&amp;quot;&lt;/code&gt;: 32 images in all.&lt;/li&gt;
&lt;li&gt;The hero is preloaded with &lt;code&gt;fetchpriority=&amp;quot;high&amp;quot;&lt;/code&gt;, and the CDN gets a &lt;code&gt;preconnect&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-else-came-out-of-the-critical-path&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-image-diet/#what-else-came-out-of-the-critical-path&quot;&gt;&lt;span&gt;What else came out of the critical path&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;An icon font was replaced with a handful of inline SVGs.&lt;/li&gt;
&lt;li&gt;The web font loads asynchronously, and the theme CSS is inlined.&lt;/li&gt;
&lt;li&gt;The tag manager waits for the first interaction, or a timeout, before loading.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;result&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-image-diet/#result&quot;&gt;&lt;span&gt;Result&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The images went from about &lt;strong&gt;3.3 MB to about 0.18 MB, 94% lighter&lt;/strong&gt;, with no visible loss on a phone. The page still looks like a luxury brand; it just doesn’t make visitors pay for it in mobile data.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Lead capture without handing the browser your CRM keys</title>
    <link href="https://www.keenan-pereira.in/blog/landing-page-crm-without-secrets/" />
    <updated>2026-10-27T06:40:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/landing-page-crm-without-secrets/</id>
    <summary>How a landing-page form sends every enquiry straight into Zoho CRM, validated and tracked, without putting a single secret in the page.</summary>
    <content type="html">&lt;p&gt;When you build a lead form for paid traffic, there are two jobs. The lead has to reach sales immediately, and nothing about how it gets there should be visible to the visitor. My first attempt at the AHA landing page did the first job and failed the second.&lt;/p&gt;
&lt;h2 id=&quot;the-tempting-shortcut&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-crm-without-secrets/#the-tempting-shortcut&quot;&gt;&lt;span&gt;The tempting shortcut&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Zoho CRM’s API uses OAuth. You hold a long-lived &lt;strong&gt;refresh token&lt;/strong&gt; and swap it for an &lt;strong&gt;access token&lt;/strong&gt; that lasts an hour. The quickest way to get a form posting into the CRM is to do that swap in the browser: call the token endpoint, then call the leads endpoint.&lt;/p&gt;
&lt;p&gt;It works on the first try, which is exactly why it’s dangerous. To make those calls, the page needs the client ID, the client secret and the refresh token. Anyone who opens the source can copy them and do whatever that token is allowed to do in your CRM. I caught this in my own draft before the page went out, and treated it as a hard stop.&lt;/p&gt;
&lt;h2 id=&quot;the-fix-one-small-endpoint&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-crm-without-secrets/#the-fix-one-small-endpoint&quot;&gt;&lt;span&gt;The fix: one small endpoint&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The browser should only submit a form. The credentials stay on a server, so I wrote a tiny Express handler for the form to post to.&lt;/p&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Simplified. The real handler reads credentials from the server environment.&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; cached &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;expires&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;accessToken&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;cached&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;token &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; Date&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; cached&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;expires&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; cached&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;token&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; res &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;token constant&quot;&gt;ZOHO_ACCOUNTS&lt;/span&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;/oauth/v2/token&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;POST&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;URLSearchParams&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;grant_type&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;refresh_token&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;refresh_token&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; process&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;env&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token constant&quot;&gt;ZOHO_REFRESH_TOKEN&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;client_id&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; process&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;env&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token constant&quot;&gt;ZOHO_CLIENT_ID&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token literal-property property&quot;&gt;client_secret&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; process&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;env&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token constant&quot;&gt;ZOHO_CLIENT_SECRET&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; access_token &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  cached &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; access_token&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;expires&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Date&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;55&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;60&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1000&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; access_token&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

app&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;/submit-lead&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;req&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; res&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; lead &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;toCrmLead&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;req&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;body&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;        &lt;span class=&quot;token comment&quot;&gt;// validate + map fields&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;!&lt;/span&gt;lead&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;422&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; r &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;token constant&quot;&gt;ZOHO_API&lt;/span&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;/crm/v2/Leads&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;POST&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;Authorization&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;Zoho-oauthtoken &lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;accessToken&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token literal-property property&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;lead&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;r&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;ok &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;201&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;502&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; r&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;ok &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details mattered more than the code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cache the access token for 55 minutes&lt;/strong&gt;, a little under its one-hour life. Refreshing it on every submission is slow, and the token endpoint will throttle you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validate on the server too.&lt;/strong&gt; The browser checks fields so people get quick feedback, but the endpoint is the real gatekeeper.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Return honest status codes.&lt;/strong&gt; The page shows a retry message if the CRM call fails, instead of a fake “thank you”. That mattered more than I knew at the time; see &lt;a href=&quot;https://www.keenan-pereira.in/blog/thank-you-page-that-lied/&quot;&gt;A thank-you page that lied&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;making-the-lead-useful-to-sales&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-crm-without-secrets/#making-the-lead-useful-to-sales&quot;&gt;&lt;span&gt;Making the lead useful to sales&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A lead the sales team has to clean up by hand is only half delivered. The form:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;requires a 10-digit phone number and a 6-digit pincode, and opens the numeric keypad on phones;&lt;/li&gt;
&lt;li&gt;splits the full name into first and last name, because the CRM requires a last name;&lt;/li&gt;
&lt;li&gt;sets the lead source, status and city, so it lands in the right view;&lt;/li&gt;
&lt;li&gt;captures property stage, property type and preferred experience centre, so a salesperson can qualify the lead before the first call.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;proving-it-converts&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-crm-without-secrets/#proving-it-converts&quot;&gt;&lt;span&gt;Proving it converts&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ads optimise for whatever conversion signal you give them, so the page pushes four events into the data layer: contact click, WhatsApp click, submit click and lead generated. A successful submit then redirects to a thank-you page marked &lt;code&gt;noindex&lt;/code&gt;. That gives Google and Meta a plain URL-based conversion that keeps working even when a tag fails.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-away&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/landing-page-crm-without-secrets/#what-i-took-away&quot;&gt;&lt;span&gt;What I took away&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;If the browser can see a secret, so can everyone.&lt;/strong&gt; “Only on my test page” lasts until it doesn’t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A 40-line proxy is cheap insurance.&lt;/strong&gt; It also gives you one place to add rate limits, spam checks and logging later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Design the lead for the person who receives it&lt;/strong&gt;, not just for the database.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>Writing about Apple without breaking Apple’s rules</title>
    <link href="https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/" />
    <updated>2026-10-27T07:00:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/</id>
    <summary>Management wanted AHA’s site to feel Apple-ish without trademark risk. Apple’s own rules became house rules, data files and a wording check first; the gold look, an Apple Home landing page and the HomeView work came after.</summary>
    <content type="html">&lt;p&gt;When Apple announced HomeView, the ask was clear: tell visitors AHA works with Apple, and make the site feel Apple-ish. Both are easy to get wrong. Apple’s trademark rules are specific, and every claim has to be literally true.&lt;/p&gt;
&lt;h2 id=&quot;what-we-could-actually-say&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/#what-we-could-actually-say&quot;&gt;&lt;span&gt;What we could actually say&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before a line was written, every claim was checked against AHA’s fact files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Compatible with Apple Home:&lt;/strong&gt; verified, and already on 13 pages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Matter and Thread certified, AirPlay on the room speakers:&lt;/strong&gt; verified.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“Works perfectly with Apple”:&lt;/strong&gt; not usable. Apple’s guidelines forbid exaggerating the Apple message, and “perfectly” is a promise no product page should make; voice control, for one, needs the internet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Siri, the Home app and an iPhone app sat behind &lt;strong&gt;five questions&lt;/strong&gt;, and nothing about them was published until the answers were in the knowledge base. Three were answered on 16 October: every AHA product shows up in Apple Home and is set up there on every install, Siri can control them, and there’s an AHA app for iPhone. The other two, a public listing for AHA’s Matter certification and a licence for Apple’s Home icon, are still open, so the badge and the icon stay off the site.&lt;/p&gt;
&lt;h2 id=&quot;apple-s-rules-written-as-house-rules&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/#apple-s-rules-written-as-house-rules&quot;&gt;&lt;span&gt;Apple’s rules, written as house rules&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;From Apple’s third-party trademark guidelines and its Works with Apple Home page:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Referential phrases only: “works with”, “compatible with”.&lt;/li&gt;
&lt;li&gt;Never possessive or plural in site copy, and never “the Apple Home”.&lt;/li&gt;
&lt;li&gt;AHA leads. Apple may appear in a title or H1 only as “compatible with Apple Home”, after AHA’s name.&lt;/li&gt;
&lt;li&gt;No Apple logo or icon without a written licence.&lt;/li&gt;
&lt;li&gt;A trademark credit wherever the marks appear.&lt;/li&gt;
&lt;li&gt;The official badge needs Matter certification and Apple’s licence, and never goes on company pages.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;encoded-not-remembered&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/#encoded-not-remembered&quot;&gt;&lt;span&gt;Encoded, not remembered&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Rules in a document get forgotten, so we built them, with Claude Code, into the files the templates read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;trademarks.js&lt;/code&gt;&lt;/strong&gt; holds one credit line naming only the Apple marks the site actually uses. The footer prints it on all &lt;strong&gt;91&lt;/strong&gt; main-site pages, and the landing-page template adds it whenever a section names an Apple product, so an editor can’t drop it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;appleHome.js&lt;/code&gt;&lt;/strong&gt; holds badge approvals, and it’s empty:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Empty until Apple approves a product.&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// The badge template renders nothing without an entry,&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// and we never draw our own badge.&lt;/span&gt;
module&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;exports &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;badges&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A wording check&lt;/strong&gt; looks for possessives, plurals, “the Apple Home”, “Apple partner” and “Apple-certified”. It came back clean on every Apple page we shipped.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;borrow-the-restraint-not-the-brand&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/#borrow-the-restraint-not-the-brand&quot;&gt;&lt;span&gt;Borrow the restraint, not the brand&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The new look took Apple’s structure: the type scale, generous spacing, borderless tiles, chevron links. On iPhone, iPad and Mac the body font is the device’s own SF Pro, through the system font stack, so there’s no download and no swap. Android and Windows keep Inter. It’s the device’s font, not an embedded Apple one.&lt;/p&gt;
&lt;p&gt;The first previews also took Apple’s greys, and the site looked dull. I asked for AHA gold everywhere. Contrast improved rather than suffered: small gold text measures &lt;strong&gt;5.0:1&lt;/strong&gt; on its band and secondary text &lt;strong&gt;5.6:1&lt;/strong&gt;, where the old grey managed &lt;strong&gt;4.3:1&lt;/strong&gt; and fell short of AA. Plain AHA gold is only 2.3:1 on white, so it’s kept for fills and rules, never text. A page joins the look with two front-matter lines, and rollback is deleting them.&lt;/p&gt;
&lt;h2 id=&quot;the-pages&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/#the-pages&quot;&gt;&lt;span&gt;The pages&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;On 16 October an Apple Home landing page went up, titled “AHA Home Automation Compatible With Apple Home”, with AHA first. Five articles sit around it, from HomeView in India to the core question, “Does AHA work with Apple Home?”. Google crawled all six on 15 and 16 October and had them indexed within days, with no indexing requests needed.&lt;/p&gt;
&lt;p&gt;The HomeView copy followed the same rules: every line checked against the fact files before it went in. The Siri cards say “Lock”, never “Unlock”, because Siri asks for authentication to unlock. HomeView sections for the landing page and a HomeView card for the homepage are &lt;strong&gt;built and going live&lt;/strong&gt;, alongside an AI-generated hero: a Mumbai living room at dusk with HomeView among AHA’s own labelled products, recomposed at 9:16 for phones, with no Apple logo.&lt;/p&gt;
&lt;h2 id=&quot;rules-i-d-pass-on&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/writing-about-apple-without-breaking-its-rules/#rules-i-d-pass-on&quot;&gt;&lt;span&gt;Rules I’d pass on&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Read the rules before writing the copy.&lt;/strong&gt; Apple publishes them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Put rules where templates read them.&lt;/strong&gt; A credit line in a data file can’t be forgotten.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gate claims on recorded answers&lt;/strong&gt;, not on what sounds right.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Borrow restraint, not branding.&lt;/strong&gt; Gold, not grey.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leave the badge slot empty until Apple fills it.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>Rules first, LLM second</title>
    <link href="https://www.keenan-pereira.in/blog/rules-first-llm-second/" />
    <updated>2026-10-27T07:20:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/rules-first-llm-second/</id>
    <summary>People don’t talk to their homes like an API. A smart-home command engine where rules answer instantly, a local LLM handles the odd phrasing, and guardrails decide what’s allowed.</summary>
    <content type="html">&lt;p&gt;I built this engine as part of AHA’s hiring process, and it set the pattern I’ve used since. The problem sounds simple, “turn speech into device actions”, until you listen to how people actually talk to a house.&lt;/p&gt;
&lt;h2 id=&quot;three-kinds-of-commands&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/rules-first-llm-second/#three-kinds-of-commands&quot;&gt;&lt;span&gt;Three kinds of commands&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Literal:&lt;/strong&gt; “turn on the bedroom lights”. A regular expression handles it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Indirect:&lt;/strong&gt; “I’m feeling hot”, “it’s too dark”, “make it cooler”. These need meaning, not matching.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compositional:&lt;/strong&gt; “everything off except the bedroom”. This is set logic over rooms and devices.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;rules-first&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/rules-first-llm-second/#rules-first&quot;&gt;&lt;span&gt;Rules first&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The rule engine runs on every command. It handles aliases and typos (“fna” → fan, “blinds” → curtains), and it understands capabilities: &lt;code&gt;set_temperature&lt;/code&gt; means the AC. When it’s confident (0.9 or higher) it answers immediately and the LLM is never called. Most real commands end here, in milliseconds.&lt;/p&gt;
&lt;p&gt;A semantic resolver sits on top for indirect language. Comfort complaints map to a device and an action. Comparatives like “cooler” and “brighter” become relative changes. “Everything except the bedroom” becomes the complement of a set of rooms.&lt;/p&gt;
&lt;h2 id=&quot;llm-second-inside-a-fence&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/rules-first-llm-second/#llm-second-inside-a-fence&quot;&gt;&lt;span&gt;LLM second, inside a fence&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Only what the rules can’t parse goes to Llama 3.2, running locally on Ollama. The prompt lists exactly which devices, rooms and capabilities exist, with a few worked examples. The engine extracts the first JSON object from the reply and &lt;strong&gt;drops anything not on the allow-list&lt;/strong&gt;. If confidence falls below 0.55, it doesn’t guess. It asks a targeted question, like “Which room’s light?”.&lt;/p&gt;
&lt;h2 id=&quot;guardrails-the-model-can-t-talk-around&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/rules-first-llm-second/#guardrails-the-model-can-t-talk-around&quot;&gt;&lt;span&gt;Guardrails the model can’t talk around&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Safety is a separate layer that applies whoever produced the action:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“Turn off everything” never includes locks or alarms unless they’re named.&lt;/li&gt;
&lt;li&gt;Unlocking and disarming need typed confirmation; voice and WhatsApp can’t do them at all.&lt;/li&gt;
&lt;li&gt;Unknown devices are rejected. The unit test for this uses a “nuclear reactor”.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;one-brain-three-ways-in&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/rules-first-llm-second/#one-brain-three-ways-in&quot;&gt;&lt;span&gt;One brain, three ways in&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The same parser, guardrail and device state serve a console, offline Whisper voice (woken by “Aha, listen”) and a WhatsApp bot. A live dashboard shows every decision: which layer answered, with what confidence, and what it did.&lt;/p&gt;
&lt;h2 id=&quot;takeaway&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/rules-first-llm-second/#takeaway&quot;&gt;&lt;span&gt;Takeaway&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;LLMs are best as the exception handler, not the main path. Put fast, testable rules in front, a strict schema around the model, and safety outside both.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Product pages led by specifications</title>
    <link href="https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/" />
    <updated>2026-10-27T07:40:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/</id>
    <summary>AHA’s ten product pages repeated their facts as bullets and showed no specifications at all. One data file now renders each spec table, the related products and the PDF catalogue, and the catalogues sit behind the lead form.</summary>
    <content type="html">&lt;p&gt;AHA’s Smart Locks page had a &lt;strong&gt;2,037 px&lt;/strong&gt; block of bullets and not one specification. The datasheet existed only inside the PDF.&lt;/p&gt;
&lt;h2 id=&quot;what-a-full-page-capture-showed&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/#what-a-full-page-capture-showed&quot;&gt;&lt;span&gt;What a full-page capture showed&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;We diagnosed before designing, and the problem wasn’t styling:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A “Why it matters” block &lt;strong&gt;2,037 px&lt;/strong&gt; tall: one dense paragraph and three bullet lists, &lt;strong&gt;26 items&lt;/strong&gt; in all.&lt;/li&gt;
&lt;li&gt;The bullets repeated facts already given in the rows above and in the printed catalogue below. Three formats, one set of facts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No specification anywhere on the page.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No link to a sibling product.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All ten product pages followed the pattern, with 4 to 32 bullets each.&lt;/p&gt;
&lt;h2 id=&quot;a-layout-led-by-specifications&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/#a-layout-led-by-specifications&quot;&gt;&lt;span&gt;A layout led by specifications&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;On 9 September all ten pages moved to one order: hero, a masthead with the headline facts and the download, story rows with alternating images, &lt;strong&gt;specification&lt;/strong&gt;, &lt;strong&gt;what it runs with&lt;/strong&gt;, why it matters, compatibility, the experience centre, contact. In plain terms: what it is, how it works, what it’s made of, what it works with, why that matters, come and see it.&lt;/p&gt;
&lt;p&gt;Two shared templates read one data file, the same one the PDF catalogue renders from, so page and catalogue can’t drift apart:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The spec table&lt;/strong&gt; replaced the hand-written feature bullets, with 4 to 20 rows per product in the same ruled layout as the printed sheet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“What it runs with”&lt;/strong&gt; added &lt;strong&gt;37 internal links&lt;/strong&gt;, 3 to 6 per page, each with a line on why that product matters to this one. The build throws on a product name with no page, so a dead card can’t ship.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Built with Claude Code, then checked at 390 and 1440 px on all ten: &lt;strong&gt;0 px&lt;/strong&gt; of sideways overflow.&lt;/p&gt;
&lt;h2 id=&quot;nothing-deleted-on-trust&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/#nothing-deleted-on-trust&quot;&gt;&lt;span&gt;Nothing deleted on trust&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Every bullet was checked against the specification data before it was removed. That found one fact that lived only in the bullets (unlocking a bedroom lock can trigger a scene), and it went into the data first. It also found a claim the product documentation didn’t support, RGB support on the lighting drivers, which left with the bullets.&lt;/p&gt;
&lt;p&gt;Where a spec isn’t confirmed, the sheet says so: the Aroma Diffuser’s sheet states that capacity, refill type, room coverage and power source aren’t published yet, rather than padding it with plausible numbers. Product prices stay off every sheet.&lt;/p&gt;
&lt;h2 id=&quot;catalogues-lighter-and-behind-the-form&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/#catalogues-lighter-and-behind-the-form&quot;&gt;&lt;span&gt;Catalogues: lighter, and behind the form&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The StarLight catalogue was &lt;strong&gt;5.4 MB&lt;/strong&gt;. It’s now &lt;strong&gt;1.5 MB&lt;/strong&gt;, and most of the other catalogues shrank with it.&lt;/p&gt;
&lt;p&gt;The cause was pixel count, not file size. Chrome decodes each image and re-encodes it into the PDF, so StarLight’s two source images, only 283 KB on the CDN, went in at 2.1 and 3.1 megapixels. Each image now has a print-sized copy, sized from what it actually renders at: about &lt;strong&gt;1.6×&lt;/strong&gt; its placed size gives 150 dpi on A4. The first attempt, a blanket 1,200 px, left the StarLight hero carrying nearly twice the pixels its layout could show.&lt;/p&gt;
&lt;p&gt;On 10 September, at my request, the catalogues went behind the lead form. A download click remembers which catalogue was asked for, scrolls to the form and relabels the button “Get the catalogue”. One submission unlocks every catalogue in that browser, and the thank-you page lists all ten with the requested one first. Three new dataLayer events (gate, unlock, download) shipped for Tag Manager, unwired at launch. It’s a soft gate: the files stay public at their own addresses.&lt;/p&gt;
&lt;h2 id=&quot;every-image-on-the-cdn-enforced-by-the-build&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/#every-image-on-the-cdn-enforced-by-the-build&quot;&gt;&lt;span&gt;Every image on the CDN, enforced by the build&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The same day I set a hard rule: every image comes from the image CDN, never from the repo. An audit of the built site found about &lt;strong&gt;1,750&lt;/strong&gt; image references, &lt;strong&gt;332&lt;/strong&gt; of them pointing at 18 local files, among them the favicon, the home-screen icon, the share image and print-sized catalogue copies.&lt;/p&gt;
&lt;p&gt;They were moved to the CDN or repointed at CDN copies, and a guard now runs after every build and &lt;strong&gt;fails it&lt;/strong&gt; on any image reference that isn’t a CDN URL. It was proven both ways: a normal build passes, and a self-test with a fake offender fails. Its first run caught the 404 page’s own favicon, which no template edit had reached. By 21 September the live site had &lt;strong&gt;zero&lt;/strong&gt; off-CDN images.&lt;/p&gt;
&lt;p&gt;One limit, found later: the guard checks the host, not whether the file exists.&lt;/p&gt;
&lt;h2 id=&quot;rules-i-d-keep&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/product-pages-led-by-specifications/#rules-i-d-keep&quot;&gt;&lt;span&gt;Rules I’d keep&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Capture the whole page before redesigning it.&lt;/strong&gt; The fault was structure, not styling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One data file for the page and the PDF&lt;/strong&gt;, so they can’t disagree.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delete nothing on trust.&lt;/strong&gt; Check each bullet against the data first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Size images by the pixels they render at&lt;/strong&gt;, not by file size.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the build enforce the rule&lt;/strong&gt;, and prove the guard can fail.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>The hero image that waited in line</title>
    <link href="https://www.keenan-pereira.in/blog/hero-image-waited-in-line/" />
    <updated>2026-10-27T08:00:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/hero-image-waited-in-line/</id>
    <summary>Two templates took over five seconds to show their main image on a slow phone. It wasn’t big; it was queued behind 23 others. Plus the caching bug that kept phones on year-old JavaScript.</summary>
    <content type="html">&lt;p&gt;Largest Contentful Paint (LCP) measures when the biggest thing on screen, usually the hero image, finishes appearing. On AHA’s &lt;code&gt;/solutions&lt;/code&gt; and &lt;code&gt;/experiences&lt;/code&gt; pages it was over five seconds on a throttled phone. The images were already compressed, so the problem wasn’t their size.&lt;/p&gt;
&lt;h2 id=&quot;diagnosis-a-traffic-jam-not-a-heavy-truck&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/hero-image-waited-in-line/#diagnosis-a-traffic-jam-not-a-heavy-truck&quot;&gt;&lt;span&gt;Diagnosis: a traffic jam, not a heavy truck&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The network waterfall told the story. The hero started downloading at the same moment as &lt;strong&gt;13 to 23 other images&lt;/strong&gt; further down the page. All of them were marked to load eagerly, and the browser treated them as equally important. The hero didn’t lose because it was big. It lost because it was waiting its turn.&lt;/p&gt;
&lt;p&gt;There was a second trap. A carousel built its slides by cloning existing image elements. The clone copied &lt;code&gt;src&lt;/code&gt; before &lt;code&gt;loading=&amp;quot;lazy&amp;quot;&lt;/code&gt;, so every cloned slide downloaded immediately, which quietly defeated lazy loading.&lt;/p&gt;
&lt;h2 id=&quot;the-fix&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/hero-image-waited-in-line/#the-fix&quot;&gt;&lt;span&gt;The fix&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tell the browser what matters.&lt;/strong&gt; The hero gets &lt;code&gt;fetchpriority=&amp;quot;high&amp;quot;&lt;/code&gt; and a preload hint. Everything below it gets &lt;code&gt;loading=&amp;quot;lazy&amp;quot;&lt;/code&gt; and explicit width and height, so nothing shifts as it loads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix the clone order.&lt;/strong&gt; Set &lt;code&gt;loading&lt;/code&gt; before &lt;code&gt;src&lt;/code&gt; and swap the real image source in only when a slide scrolls into view. Image requests at load dropped from 22 to 2.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fake the blur, don’t compute it.&lt;/strong&gt; A CSS &lt;code&gt;filter: blur()&lt;/code&gt; over a full-screen hero costs real time on a weak phone, so the hero now uses a pre-blurred image.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resize to the layout.&lt;/strong&gt; &lt;code&gt;/contact-us&lt;/code&gt; went from a 495 KB hero to 75 KB, with a 1024 px version in &lt;code&gt;srcset&lt;/code&gt; for phones.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-other-bug-phones-running-year-old-javascript&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/hero-image-waited-in-line/#the-other-bug-phones-running-year-old-javascript&quot;&gt;&lt;span&gt;The other bug: phones running year-old JavaScript&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Some returning visitors saw blank sliders. Their phones were running JavaScript from months earlier. The server told browsers to cache scripts and stylesheets as &lt;strong&gt;immutable for a year&lt;/strong&gt;, but the filenames never changed. When we shipped a fix, those phones had no reason to fetch it.&lt;/p&gt;
&lt;p&gt;The fix is the standard one, applied properly: every CSS and JS file gets an MD5 hash of its contents in its filename, at build time, after minification. Change one byte and you get a new filename, so caching forever becomes safe instead of dangerous.&lt;/p&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// eleventy.after: hash, rename, rewrite references&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; hash &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;createHash&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;md5&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;code&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;digest&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;hex&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;slice&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; hashed &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; file&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token regex&quot;&gt;&lt;span class=&quot;token regex-delimiter&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;token regex-source language-regex&quot;&gt;&#92;.(js|css)$&lt;/span&gt;&lt;span class=&quot;token regex-delimiter&quot;&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;hash&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;.$1&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/hero-image-waited-in-line/#results&quot;&gt;&lt;span&gt;Results&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Lighthouse lab runs, slow 4G with a 4× CPU throttle, before and after:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/solutions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.44 s&lt;/td&gt;
&lt;td&gt;1.11 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/experiences&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.15 s&lt;/td&gt;
&lt;td&gt;1.18 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/contact-us&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.95 s&lt;/td&gt;
&lt;td&gt;1.48 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt; (home)&lt;/td&gt;
&lt;td&gt;2.68 s&lt;/td&gt;
&lt;td&gt;1.44 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;one-more-lesson&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/hero-image-waited-in-line/#one-more-lesson&quot;&gt;&lt;span&gt;One more lesson&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The lab numbers passed, but real-user data stayed slower than they suggested. The giveaway was that the first paint arrived almost as late as the main image, and the server took about 1.2 s to respond. Past a point, the bottleneck is the hosting, not the page. That fix is documented and waiting on infrastructure access.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>What a fortnight of Google Business Profile posts taught us</title>
    <link href="https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/" />
    <updated>2026-10-27T08:20:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/</id>
    <summary>We turned ad hoc Business Profile posting into a tagged post bank for both AHA centres. Three readings later, we had to correct our own claim about how long a post keeps earning.</summary>
    <content type="html">&lt;p&gt;A Google Business Profile post earns its views in about two weeks and then stops dead. I know because we first wrote down the opposite, and a third reading proved it wrong.&lt;/p&gt;
&lt;h2 id=&quot;where-it-started&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/#where-it-started&quot;&gt;&lt;span&gt;Where it started&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;AHA has two experience centres in Mumbai, Santacruz West and Lower Parel, and each has its own Business Profile. Until mid-September, posting to them was ad hoc. On 9 September the plan still listed the profiles as &lt;strong&gt;unstarted&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;a-post-bank-not-a-habit&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/#a-post-bank-not-a-habit&quot;&gt;&lt;span&gt;A post bank, not a habit&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;From 15 September, posting ran from a bank instead of memory:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One description per article&lt;/strong&gt;, rewritten for curiosity. Each opens on a specific, sometimes surprising claim from the article and holds back the answer. All sit well under Google’s &lt;strong&gt;1,500-character&lt;/strong&gt; limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One tagged link per post&lt;/strong&gt;, used on both centres, with the publish date in &lt;code&gt;utm_content&lt;/code&gt;. Clicks are attributable in GA4 instead of landing as unknown traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Both centres, every time&lt;/strong&gt;, using Google’s own “Copy post” dialog.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A fixed cadence:&lt;/strong&gt; Monday and Thursday, with Friday added a week later. Mumbai and buying-intent articles go first, and no two posts in a row share a topic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A photo of the post’s own topic&lt;/strong&gt;, never a logo card, exported as JPG because Business Profile won’t accept the site’s AVIF images. The photos prepared on 28 September, for example, were two JPGs: one for the retrofit article and one for TV-unit wall planning.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;?utm_source=gbp&amp;utm_medium=post
 &amp;utm_campaign=blog/&lt;slug&gt;&amp;utm_content=&lt;publish date=&quot;&quot;&gt;&lt;/publish&gt;&lt;/slug&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bank fell behind once, with descriptions for &lt;strong&gt;19 of 30&lt;/strong&gt; published articles, because writing one was a separate task nobody had queued. Writing it is now a standing step in the Claude Code skill that publishes articles, so it happens in the same session a post ships.&lt;/p&gt;
&lt;p&gt;Google declined our Business Profile API application automatically, so the readings came from screenshots of the profiles.&lt;/p&gt;
&lt;h2 id=&quot;the-claim-we-got-wrong&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/#the-claim-we-got-wrong&quot;&gt;&lt;span&gt;The claim we got wrong&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The first four posts went up on 16 September. Between the first reading (day 5) and the second (day 15), their combined views rose &lt;strong&gt;93%&lt;/strong&gt;. On 1 October the log called it a long tail, “GBP posts keep earning for weeks”, and the plan was to work through every older article on that basis.&lt;/p&gt;
&lt;p&gt;The third reading, on 6 October, said otherwise:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Six of seven posts gained nothing in five days.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The first four went from &lt;strong&gt;+93%&lt;/strong&gt; in ten days to &lt;strong&gt;0%&lt;/strong&gt; in the next five. Not slower growth: identical numbers. That’s a plateau, not a reporting lag.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The 93% was real. It was just the end of the fortnight, and we had read the last of a curve as a trend.&lt;/p&gt;
&lt;h2 id=&quot;the-exception-that-explains-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/#the-exception-that-explains-it&quot;&gt;&lt;span&gt;The exception that explains it&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;One post kept climbing: an article on October heat in Mumbai, posted in late September. It rose &lt;strong&gt;38% in five days&lt;/strong&gt; while everything else was flat. A Diwali post from the same week went out about seven weeks before the festival. Same effort, same format, and the October post had &lt;strong&gt;nearly four times&lt;/strong&gt; the views. The only difference was whether the topic was live when it went out.&lt;/p&gt;
&lt;h2 id=&quot;what-changed&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/#what-changed&quot;&gt;&lt;span&gt;What changed&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cadence still matters, for a duller reason.&lt;/strong&gt; Each post is a one-off that earns for about two weeks, so value grows with the number of posts, not with time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Season is the biggest multiplier.&lt;/strong&gt; Festive posts go out when the festival is close. The Diwali article is scheduled for a re-post on 2 November, and that’s the main event, not a tidy-up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-posting a good evergreen article every couple of months&lt;/strong&gt; is now a tactic worth testing, because the first post has provably stopped earning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Size it honestly.&lt;/strong&gt; Twelve posts in three weeks make a useful supporting channel, not a main line.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;still-open&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/#still-open&quot;&gt;&lt;span&gt;Still open&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The profiles themselves aren’t at 100% yet. A phone number added to the Lower Parel profile on 22 September still wasn’t showing in Google’s public data a month later, and the Friday review-request habit is still on the open list. That matters more than any single post: on commercial searches, the Santacruz listing averages &lt;strong&gt;position 3&lt;/strong&gt;, where the homepage averages 16.&lt;/p&gt;
&lt;h2 id=&quot;rules-i-took-from-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/google-business-profile-posts-fade-in-a-fortnight/#rules-i-took-from-it&quot;&gt;&lt;span&gt;Rules I took from it&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read a channel three times before calling a trend.&lt;/strong&gt; Two points make a line; three show its shape.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identical numbers mean a plateau&lt;/strong&gt;, not a lag.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Post in season.&lt;/strong&gt; Timing beat everything else we controlled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tag every link&lt;/strong&gt;, or the visits belong to nobody.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>The sitemap that quietly hid the whole blog</title>
    <link href="https://www.keenan-pereira.in/blog/sitemap-that-hid-the-blog/" />
    <updated>2026-10-27T08:40:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/sitemap-that-hid-the-blog/</id>
    <summary>A hand-kept sitemap drifted, then a misread code comment silently dropped every blog post. The fix was to stop writing the sitemap by hand.</summary>
    <content type="html">&lt;p&gt;A sitemap is the list of pages you hand to Google. Most of the time nobody looks at it, which is how it can be wrong for months.&lt;/p&gt;
&lt;h2 id=&quot;how-it-went-wrong&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/sitemap-that-hid-the-blog/#how-it-went-wrong&quot;&gt;&lt;span&gt;How it went wrong&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The site I took over had a sitemap written by hand. It had 31 entries, used the bare domain instead of &lt;code&gt;www&lt;/code&gt;, still listed old URL patterns, had one entry missing &lt;code&gt;.com&lt;/code&gt; entirely and listed one page twice. Every time someone added a page, someone else had to remember to add it to the sitemap. Usually nobody did.&lt;/p&gt;
&lt;p&gt;The worst failure was quieter. The site is built with Eleventy, and the sitemap logic skipped any page it believed was marked &lt;code&gt;noindex&lt;/code&gt;. A code comment in the blog’s template was misread as that flag, so every blog post silently dropped out of the sitemap. Nothing errored, nothing turned red, and Google simply got a shorter list.&lt;/p&gt;
&lt;p&gt;Later, the same kind of drift hit pagination: page 2 onwards of the blog listing was skipped.&lt;/p&gt;
&lt;h2 id=&quot;the-fix-derive-it-don-t-maintain-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/sitemap-that-hid-the-blog/#the-fix-derive-it-don-t-maintain-it&quot;&gt;&lt;span&gt;The fix: derive it, don’t maintain it&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The sitemap is now generated from two sources of truth that already have to be right for the site to work:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The built pages.&lt;/strong&gt; Every page that isn’t explicitly excluded (redirect stubs, thank-you pages, ads-only landing pages) is included.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The blog taxonomy data.&lt;/strong&gt; It feeds categories, topic hubs and paginated archives, so a new topic hub appears in the sitemap the moment it exists.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Three rules keep it honest:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exclusion is explicit.&lt;/strong&gt; A page leaves the sitemap only through a real front-matter flag, never through text that happens to appear in a template.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The canonical host is enforced.&lt;/strong&gt; Every URL is built from one base address, so &lt;code&gt;www&lt;/code&gt; and the trailing slashes always match.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every deploy checks what it shipped.&lt;/strong&gt; A live check requests every URL in the new sitemap and fails loudly on anything that isn’t a 200.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same idea now generates &lt;code&gt;llms.txt&lt;/code&gt;, the plain-text index some AI tools read. It went from listing 66 URLs to all 107.&lt;/p&gt;
&lt;h2 id=&quot;results&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/sitemap-that-hid-the-blog/#results&quot;&gt;&lt;span&gt;Results&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The sitemap grew from &lt;strong&gt;38 URLs to 108&lt;/strong&gt; as the content programme shipped, with no manual edits.&lt;/li&gt;
&lt;li&gt;By late October, &lt;strong&gt;106 of the 107&lt;/strong&gt; submitted pages were indexed.&lt;/li&gt;
&lt;li&gt;The blog, which was invisible in the sitemap for a while, now brings in more than half of all non-brand clicks.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;takeaway&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/sitemap-that-hid-the-blog/#takeaway&quot;&gt;&lt;span&gt;Takeaway&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If a file can be derived from data you already maintain, derive it. A hand-kept sitemap isn’t wrong on the day it’s written; it goes wrong slowly, and silently.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Don’t let the LLM do the maths</title>
    <link href="https://www.keenan-pereira.in/blog/dont-let-the-llm-do-the-maths/" />
    <updated>2026-10-27T09:00:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/dont-let-the-llm-do-the-maths/</id>
    <summary>My first CRM insights agent asked an LLM to summarise the numbers, and it got them wrong. Every data agent I’ve built since computes in SQL and lets the model narrate, if it needs a model at all.</summary>
    <content type="html">&lt;p&gt;The first version of my CRM insights agent did what most tutorials suggest. It fetched the data, put it in the prompt and asked the model for a summary of the day.&lt;/p&gt;
&lt;p&gt;The output read beautifully, and it was wrong. In one test run, the model &lt;strong&gt;dropped a digit from a rupee figure&lt;/strong&gt; and described a lead &lt;em&gt;status&lt;/em&gt; as a marketing &lt;em&gt;channel&lt;/em&gt;. Nobody would have noticed until a manager quoted the number in a meeting.&lt;/p&gt;
&lt;h2 id=&quot;why-this-keeps-happening&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/dont-let-the-llm-do-the-maths/#why-this-keeps-happening&quot;&gt;&lt;span&gt;Why this keeps happening&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Language models predict text. Adding up 400 rows, or noticing that a field means something subtle, is arithmetic and schema knowledge: precise work that fails unpredictably. Prompting harder doesn’t fix a tool that’s the wrong shape for the job.&lt;/p&gt;
&lt;h2 id=&quot;split-the-work&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/dont-let-the-llm-do-the-maths/#split-the-work&quot;&gt;&lt;span&gt;Split the work&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Postgres does the maths.&lt;/strong&gt; I deleted about 255 lines of Python analytics and replaced them with 327 lines of SQL functions: funnel counts, conversion rates, pipeline by stage, and anomaly checks. Anomalies are fixed rules, not vibes. Examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a salesperson holding a pile of active leads with almost no pipeline is flagged as overloaded;&lt;/li&gt;
&lt;li&gt;a lead source where at least 30% of leads are junk is flagged as toxic.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The model only narrates.&lt;/strong&gt; It receives one compact JSON payload of already-computed figures and writes two sections from one prompt: a dashboard report and a WhatsApp summary. The summary is capped at 1,500 characters and cut at a line break, because WhatsApp’s limit is 1,600.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Then I took even that away for the numbers people forward.&lt;/strong&gt; The WhatsApp message became a fixed template filled with the computed values. The model still writes the commentary, but it never retypes a figure.&lt;/p&gt;
&lt;h2 id=&quot;the-same-idea-at-a-bigger-scale&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/dont-let-the-llm-do-the-maths/#the-same-idea-at-a-bigger-scale&quot;&gt;&lt;span&gt;The same idea at a bigger scale&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The growth-intelligence platform has a chat where managers ask questions. Most questions are numbers: “demos last week?”, “leads from Meta this month?”. Those go to a &lt;strong&gt;deterministic router&lt;/strong&gt;, about 14 SQL “skills” that run read-only queries on allow-listed views with an 8-second timeout and a row cap. They answer in under a second, for nothing, and they’re correct by construction.&lt;/p&gt;
&lt;p&gt;Only “why” questions (why did demos drop?) reach Claude, along with the relevant computed numbers. The model does what it’s good at: connecting facts and explaining them.&lt;/p&gt;
&lt;h2 id=&quot;rules-i-now-build-every-data-agent-with&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/dont-let-the-llm-do-the-maths/#rules-i-now-build-every-data-agent-with&quot;&gt;&lt;span&gt;Rules I now build every data agent with&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;If it can be computed, compute it.&lt;/strong&gt; Never ask a model for a sum.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give the model a payload, not the database.&lt;/strong&gt; Small, typed and precomputed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Templates for anything people will forward.&lt;/strong&gt; Numbers in fixed slots; prose around them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Route first.&lt;/strong&gt; Most “AI” questions are SQL questions in disguise.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>Building for a 2 GB TV and a remote with no pointer</title>
    <link href="https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/" />
    <updated>2026-10-27T09:20:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/</id>
    <summary>The showroom’s main screen is a TV with little memory, a weak GPU, cropped edges and a D-pad remote. What it took to make a map of 500 buildings usable on it.</summary>
    <content type="html">&lt;p&gt;The deals map started as a desktop tool: every Mumbai building where AHA has automated homes, with plans, architects and what’s nearby. Then someone asked the obvious question. Could it run on the big screen in the experience centre, so a consultant can say “we’ve done twelve homes in your building” and show them?&lt;/p&gt;
&lt;p&gt;The big screen is a TV, and TVs are hostile places for web apps.&lt;/p&gt;
&lt;h2 id=&quot;constraint-1-a-remote-has-no-pointer&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/#constraint-1-a-remote-has-no-pointer&quot;&gt;&lt;span&gt;Constraint 1: a remote has no pointer&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A D-pad gives you up, down, left, right and OK. Picking one marker out of 500 by stepping through them is miserable.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zone jump (the old TV mode).&lt;/strong&gt; The map split into a 3×3 grid, and each press narrowed it to one ninth. About 500 buildings came down to a handful in two or three presses instead of dozens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A magnetic, accelerating cursor.&lt;/strong&gt; Holding a direction speeds up, and the cursor snaps to the nearest marker when it gets close.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Holding OK, four ways.&lt;/strong&gt; TV browsers report a held OK key in four different ways, so one small function recognises all four as “hold”.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;constraint-2-the-edges-don-t-exist&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/#constraint-2-the-edges-don-t-exist&quot;&gt;&lt;span&gt;Constraint 2: the edges don’t exist&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;TVs crop 2–5% of the picture at the edges (overscan), so a button in the corner may simply not be on screen. The old TV mode had an overscan calibration step, and laid itself out inside the safe area it measured.&lt;/p&gt;
&lt;h2 id=&quot;constraint-3-back-leaves-the-app&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/#constraint-3-back-leaves-the-app&quot;&gt;&lt;span&gt;Constraint 3: BACK leaves the app&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;On the TV browser, BACK left our app and opened an old cached copy of the page. Now each BACK press is absorbed by one step of in-app history (up to 40), and a service worker forces fresh loads so a stale copy can’t come back.&lt;/p&gt;
&lt;h2 id=&quot;constraint-4-2-gb-and-a-weak-gpu&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/#constraint-4-2-gb-and-a-weak-gpu&quot;&gt;&lt;span&gt;Constraint 4: 2 GB and a weak GPU&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LITE mode.&lt;/strong&gt; The map draws at the screen’s native size instead of a high-DPI multiple. Browser memory fell from about 1,000 MB to about 650 MB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compositor-only animation.&lt;/strong&gt; Everything animates with &lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt; only, so the GPU composites layers instead of repainting. Measured: the curtains scene went from 20 to 57 fps, the smart-lock face scan from 25 to 60, and the moments reel from 30 to 60.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Heavy things are pre-baked.&lt;/strong&gt; Floor plans are pre-rendered from PDF to 1,800 px WebP (200–350 KB each) in headless Chrome, so the TV never parses a PDF. Vertical product films were re-encoded from 23–35 MB down to 1.5–2.8 MB.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;constraint-5-two-audiences-one-screen&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/#constraint-5-two-audiences-one-screen&quot;&gt;&lt;span&gt;Constraint 5: two audiences, one screen&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Visitors must never see money, deal status or salesperson names. Showroom view hides every amount and deal status on screen. Team view, for staff, adds the CRM calendar and billboard planning, and switches itself off after 10 idle minutes or when the screen sleeps.&lt;/p&gt;
&lt;h2 id=&quot;the-pitch-edited&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/#the-pitch-edited&quot;&gt;&lt;span&gt;The pitch, edited&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Even a working app can be the wrong length. The first guided walkthrough took 15 minutes. “Present” is now seven chapters that play themselves, about &lt;strong&gt;5½ minutes&lt;/strong&gt; when a presenter clicks through.&lt;/p&gt;
&lt;h2 id=&quot;takeaway&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/#takeaway&quot;&gt;&lt;span&gt;Takeaway&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Design for the worst device first. Nearly every TV constraint, from fewer pixels and less memory to clearer navigation and shorter stories, made the laptop version better too.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Where did this lead come from?</title>
    <link href="https://www.keenan-pereira.in/blog/where-did-this-lead-come-from/" />
    <updated>2026-10-27T09:40:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/where-did-this-lead-come-from/</id>
    <summary>AHA’s website leads reached Zoho with no record of the page they came from. How every lead now carries its page, its first page and a best time to call, and how ad tags will survive a second visit.</summary>
    <content type="html">&lt;p&gt;A lead without a source is a phone number and a guess. In October I found that AHA’s two Zoho fields for the page a lead came from were blank on &lt;strong&gt;every&lt;/strong&gt; website lead. No lead could be traced to a page.&lt;/p&gt;
&lt;p&gt;It wasn’t the only gap I found that autumn:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ad tags didn’t survive a second visit.&lt;/strong&gt; UTM tags and Google’s click ID lived in session storage, so an ad visitor who came back in a new tab arrived as a stranger. Almost every lead credited to the website had no tags and no click ID.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The click ID went to the wrong field.&lt;/strong&gt; Forms wrote it to a custom field. Zoho’s standard GCLID field, the one its Google Ads integration and offline conversion uploads read, was empty on every lead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zoho had a “Preferred Time For Callback” field.&lt;/strong&gt; No form sent it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phone taps weren’t counted.&lt;/strong&gt; The GA4 key event for calls had recorded nothing for 28 days, and the event that did fire wasn’t a key event.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;every-lead-records-its-page&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/where-did-this-lead-come-from/#every-lead-records-its-page&quot;&gt;&lt;span&gt;Every lead records its page&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Every visit now stores its first page. All seven forms add two fields to the end of the lead: the page the form was sent from, without the query string, and the first page of the visit. Both are cut to Zoho’s 255-character limit.&lt;/p&gt;
&lt;p&gt;One field was left out on purpose. Zoho also has a URL-type field, but a bare path fails its URL validation, and that could make Zoho refuse the whole lead.&lt;/p&gt;
&lt;p&gt;I had Claude Code test nine form placements against five kinds of visit: direct, tagged, an organic landing on a blog post, a tagged landing on another page, and a visit that began before the change. &lt;strong&gt;45 of 45&lt;/strong&gt; sent one lead with both fields right. Against the previous build, every payload was identical apart from the two new fields. It went live on 24 October.&lt;/p&gt;
&lt;h2 id=&quot;tags-that-survive-a-second-visit&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/where-did-this-lead-come-from/#tags-that-survive-a-second-visit&quot;&gt;&lt;span&gt;Tags that survive a second visit&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The tag fix is built and tested, and going live now, so there are no results to report yet. The rule it implements:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Which source a lead carries (simplified)&lt;/span&gt;
source &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; tagsFromThisVisit          &lt;span class=&quot;token comment&quot;&gt;// first tagged page of the visit wins&lt;/span&gt;
      &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;savedTags&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;maxAgeDays&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;90&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// restored on an untagged return&lt;/span&gt;
      &lt;span class=&quot;token operator&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;fromReferrer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;               &lt;span class=&quot;token comment&quot;&gt;// e.g. &quot;google / organic&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;One capture script, inlined everywhere, replaces six hand-kept copies.&lt;/li&gt;
&lt;li&gt;A newer tagged visit replaces saved tags. A tag saved 100 days ago is dropped.&lt;/li&gt;
&lt;li&gt;Every form sends the click ID to Zoho’s standard field as well as the custom one.&lt;/li&gt;
&lt;li&gt;Untagged visits get a source from the referrer: a search engine becomes organic, no referrer becomes direct, and social networks and other sites, AI assistants included, are recorded by name. Lead Source itself doesn’t change.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The tag tests passed 34 of 34 in each of Chrome, Firefox and Safari’s engine, and the referrer cases 36 of 36. One known limit: Safari on iPhone can clear saved tags after seven days without a visit.&lt;/p&gt;
&lt;p&gt;The new Ads landing page, &lt;code&gt;/top-home-automation-companies&lt;/code&gt;, sends the same page, tag and callback fields, and is going live the same way.&lt;/p&gt;
&lt;h2 id=&quot;ask-when-to-call&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/where-did-this-lead-come-from/#ask-when-to-call&quot;&gt;&lt;span&gt;Ask when to call&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The callback field became an optional dropdown on all seven forms, with four slots that match the experience centres’ hours: 11am to 1pm, 1pm to 4pm, 4pm to 7pm, or any time. Optional, because every required field costs leads.&lt;/p&gt;
&lt;p&gt;After it shipped, a test lead read back from Zoho with “4pm to 7pm”. The sales team’s WhatsApp alert now shows the slot too, and the first real enquiry to use it asked for 11am to 1pm. Testing this also exposed two Ads forms that couldn’t submit at all, which is &lt;a href=&quot;https://www.keenan-pereira.in/blog/thank-you-page-that-lied/&quot;&gt;its own story&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;count-the-taps&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/where-did-this-lead-come-from/#count-the-taps&quot;&gt;&lt;span&gt;Count the taps&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Calls and WhatsApp chats are enquiries too. One listener now catches every call and WhatsApp link and pushes a &lt;code&gt;contact_click&lt;/code&gt; event with the method, where on the page it sat (floating button, footer, form) and the page path. GA4’s key events are now &lt;code&gt;generate_lead&lt;/code&gt; and &lt;code&gt;contact_click&lt;/code&gt;, and the old tap events were unstarred, because each tap fired both and would have counted twice. Counts before and after 22 September aren’t comparable, and I logged it that way.&lt;/p&gt;
&lt;h2 id=&quot;rules&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/where-did-this-lead-come-from/#rules&quot;&gt;&lt;span&gt;Rules&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A tracking field must never be able to lose the lead.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Record the first page of the visit, not just the last.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write the click ID where the integration reads it.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optional beats required.&lt;/strong&gt; Ask what helps the call, not the report.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When you change what’s counted, date it.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>A phone as the TV remote, with voice search</title>
    <link href="https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/" />
    <updated>2026-10-27T10:00:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/</id>
    <summary>When the deals map first went full screen on a TV, a phone became the remote. Say a building’s name in Indian English, and a fuzzy matcher allows for the syllables the recogniser mis-hears.</summary>
    <content type="html">&lt;p&gt;In July 2026 the deals map got a full-screen TV mode, and with it a question the desktop version never had to answer: with the map on a TV across the room, how do you find one building by name? My answer was a phone. I built it with Claude Code.&lt;/p&gt;
&lt;h2 id=&quot;kiosk-mode&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/#kiosk-mode&quot;&gt;&lt;span&gt;Kiosk mode&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;One button switches the map to kiosk mode. The sidebar goes, open menus close and the map resizes to fill the screen. Adding &lt;code&gt;?tv=1&lt;/code&gt; to the address opens the page straight into it, so it can be AirPlayed or opened in a TV browser with nothing to click.&lt;/p&gt;
&lt;h2 id=&quot;one-channel-two-screens&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/#one-channel-two-screens&quot;&gt;&lt;span&gt;One channel, two screens&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The remote is a small web page opened on a phone. It and the map join the same Supabase Realtime channel and use two of its features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Broadcast&lt;/strong&gt; carries commands from the phone to the TV, and results back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Presence&lt;/strong&gt; tells each side who’s there. The phone reads “Waiting for TV…” until a map joins as the TV, then “TV connected”. The TV shows a toast when a phone pairs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The phone is deliberately thin. It doesn’t search anything itself: it sends what it heard, and the TV runs the same matcher as its own mic button. Below the mic, a D-pad pans and zooms the map, resets it to Mumbai, closes the building card and toggles TV mode.&lt;/p&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// phone → TV&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;search&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;q&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;what it heard&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token literal-property property&quot;&gt;alts&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token comment&quot;&gt;/* up to 4 guesses */&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; id &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// TV → phone&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; ok&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; label&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; q&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; suggestions&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; id &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; earned its place. On a broadcast channel every map page answers, and an old map tab left open from a test stays connected and replies to every command too. Its late answer could overwrite the real one. So the phone ignores any reply that isn’t for its latest search.&lt;/p&gt;
&lt;h2 id=&quot;hearing-names-in-indian-english&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/#hearing-names-in-indian-english&quot;&gt;&lt;span&gt;Hearing names in Indian English&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Voice uses the browser’s own speech recognition, set to Indian English (&lt;code&gt;en-IN&lt;/code&gt;). The phone shows the words as they’re heard and asks the recogniser for up to &lt;strong&gt;4&lt;/strong&gt; guesses at what was said. All of them go to the TV, every building is scored against each guess, and the best score wins.&lt;/p&gt;
&lt;p&gt;Building names still get mis-heard, and an exact lookup fails on one wrong syllable. So the matcher works in tiers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Close text matches.&lt;/strong&gt; An exact match scores 100, one name starting with the other 88, one containing the other 68. Words in common score 22 each, up to 55.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A fuzzy fallback.&lt;/strong&gt; Edit distance counts the single-letter changes that turn one string into the other. About &lt;strong&gt;77%&lt;/strong&gt; similar is enough to fly the map to the building; a weaker match can only be suggested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Common words count for less.&lt;/strong&gt; Building names often start with the developer’s name, and that word alone can’t say which one you meant. The matcher counts how many buildings each word appears in, and a fuzzy match on a word shared by &lt;strong&gt;3&lt;/strong&gt; or more is capped below the jump line. It can suggest; it can’t decide.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;sure-or-not-sure&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/#sure-or-not-sure&quot;&gt;&lt;span&gt;Sure, or not sure&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If the best score clears the line (28 out of 100), the TV zooms in, centres the building and opens its card, and the phone says “Found”. If not, the TV says there’s no project by that name, and the phone shows up to &lt;strong&gt;3&lt;/strong&gt; “Did you mean” chips: the closest names the map actually has. Tapping one runs that search, so a near miss costs a tap, not a second try at the name.&lt;/p&gt;
&lt;h2 id=&quot;where-it-went-next&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/#where-it-went-next&quot;&gt;&lt;span&gt;Where it went next&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The phone remote was the deals map’s first answer to the TV; it later grew into the showroom TV app, driven by the TV’s own remote, which brought problems of its own: &lt;a href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/&quot;&gt;building for a 2 GB TV and a remote with no pointer&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-i-d-keep&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-as-the-tv-remote/#what-i-d-keep&quot;&gt;&lt;span&gt;What I’d keep&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use the device in people’s pockets.&lt;/strong&gt; A phone already has a microphone and a touchscreen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the remote thin.&lt;/strong&gt; One matcher on the TV serves the phone and the TV’s own mic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ask for several guesses.&lt;/strong&gt; Score every alternative the recogniser offers, not just its favourite.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use two thresholds.&lt;/strong&gt; Jump when sure, suggest when not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tag every request.&lt;/strong&gt; On a broadcast channel, anyone can answer.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>Measuring whether AI assistants recommend you</title>
    <link href="https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/" />
    <updated>2026-10-27T10:20:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/</id>
    <summary>No dashboard says whether ChatGPT recommends you. A fixed panel of 12 buyer prompts on two AI engines turned that into a number for AHA, and showed what to fix on the site.</summary>
    <content type="html">&lt;p&gt;When a buyer asks ChatGPT for the best home automation company in Mumbai, does it name AHA? When I took over AHA’s website in August, nothing was measuring that, and no dashboard could.&lt;/p&gt;
&lt;h2 id=&quot;why-you-can-t-just-look-it-up&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/#why-you-can-t-just-look-it-up&quot;&gt;&lt;span&gt;Why you can’t just look it up&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Search Console’s API has no AI Overview breakdown. Bing has an AI Performance report in preview, but it covers only Microsoft’s own answers, and I found no API for it. For ChatGPT and Perplexity, the only way to know is to ask them.&lt;/p&gt;
&lt;p&gt;What Bing did show, in September, was sobering: only &lt;strong&gt;4 of AHA’s 70 pages&lt;/strong&gt; had any AI citation at all.&lt;/p&gt;
&lt;h2 id=&quot;the-panel&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/#the-panel&quot;&gt;&lt;span&gt;The panel&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So I set up a measure with Claude Code: a fixed panel of &lt;strong&gt;12 buyer prompts&lt;/strong&gt;, from wired versus wireless, to the best company in Mumbai, to adding automation to a finished flat without breaking walls. Each runs on &lt;strong&gt;2 engines&lt;/strong&gt;, ChatGPT and Perplexity, logged out, in a fresh chat every time. That’s 24 answers a run.&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;for each of the 12 fixed prompts:
  for each engine in [ChatGPT, Perplexity]:     # logged out, fresh chat
    log: AHA named? · source cited · claim made about AHA · notes
score = answers naming AHA, out of 24&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The wording never changes. Edit one prompt and the trend means nothing. “Named” and “cited” are logged separately, because an engine can use your page as a source without saying your name.&lt;/p&gt;
&lt;h2 id=&quot;the-baseline&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/#the-baseline&quot;&gt;&lt;span&gt;The baseline&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The first run, on 21 September, named AHA in &lt;strong&gt;5 of 24&lt;/strong&gt; answers. Some were strong: Perplexity named AHA first for the best company in Mumbai and cited AHA’s own site. But none of the six explanatory prompts named AHA, and three more answers used AHA’s pages as a source without naming it. The single most-cited source was a directory site, in 5 answers.&lt;/p&gt;
&lt;p&gt;The retrofit prompt, adding automation to a finished flat without breaking walls, is AHA’s core work. Neither engine named AHA.&lt;/p&gt;
&lt;h2 id=&quot;what-i-changed&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/#what-i-changed&quot;&gt;&lt;span&gt;What I changed&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Let AI crawlers in.&lt;/strong&gt; robots.txt explicitly allows the AI crawlers from OpenAI, Anthropic and Perplexity. In October, all 11 AI crawler user agents tested got a 200 from the live site.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bing, properly.&lt;/strong&gt; Webmaster Tools and the IndexNow key went live on 5 September, but nothing was submitted until 15 September. Then one push sent all &lt;strong&gt;70&lt;/strong&gt; live URLs. IndexNow speeds discovery, not ranking, and Google doesn’t use it. The payoff is Bing, which ChatGPT search and Copilot draw on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A complete llms.txt&lt;/strong&gt;, the plain-text index some AI tools read. It now comes from the same data as the sitemap, so it lists &lt;strong&gt;107 of 107&lt;/strong&gt; URLs, up from 66, under a short block of verified facts, including what AHA does and doesn’t do.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One Organization.&lt;/strong&gt; Twelve hand-copied Organization blocks, and 14 blog publishers using an old brand name, became one node with one &lt;code&gt;@id&lt;/code&gt; across the site, with a description, address and service area.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content in the HTML.&lt;/strong&gt; Three homepage sliders were empty until JavaScript built them, so a crawler that doesn’t run JavaScript saw no cards. Their cards are now in the HTML.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;a-month-later&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/#a-month-later&quot;&gt;&lt;span&gt;A month later&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The second run, on 22 October, named AHA in &lt;strong&gt;7 of 24&lt;/strong&gt; answers, and named or cited it in 10, up from 8. Perplexity added the Bandra and architects prompts. Bing’s index of the site grew from &lt;strong&gt;84 to 112&lt;/strong&gt; pages.&lt;/p&gt;
&lt;p&gt;It moves both ways. On the architects prompt, ChatGPT dropped AHA from first to eighth of eight. One answer said AHA installs hardware, which it doesn’t; another quoted an old page title with an out-of-date company age. AI answers lag the site by weeks.&lt;/p&gt;
&lt;p&gt;The retrofit prompt still names AHA on neither engine, so the work isn’t finished. The same review found “retrofit” in no title, H1 or H2 on the site. The llms.txt, Organization and slider changes were built after this run, so the next run is the first that can show their effect.&lt;/p&gt;
&lt;h2 id=&quot;what-i-d-tell-anyone-starting&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/measuring-ai-search-visibility/#what-i-d-tell-anyone-starting&quot;&gt;&lt;span&gt;What I’d tell anyone starting&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Measure before you optimise&lt;/strong&gt;, with prompts you never edit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Count “named” and “cited” separately.&lt;/strong&gt; Being a source isn’t being recommended.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the facts easy to lift:&lt;/strong&gt; crawlable HTML, one entity, a complete llms.txt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expect lag&lt;/strong&gt;, and don’t over-read one run of 24 answers.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>A phone page that tells consultants who to call next</title>
    <link href="https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/" />
    <updated>2026-10-27T10:40:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/</id>
    <summary>A screen of totals doesn’t tell a consultant who to ring. How a whiteboard sketch became a read-only phone page with a ranked call list, and why most other screens were cut.</summary>
    <content type="html">&lt;p&gt;A screen of totals tells a sales consultant how the month is going. It doesn’t tell them who to ring this morning. AHA’s consultant page was built for that: a single page on a phone, in plain words, with a short list of who to call first.&lt;/p&gt;
&lt;h2 id=&quot;start-from-a-whiteboard-not-a-schema&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/#start-from-a-whiteboard-not-a-schema&quot;&gt;&lt;span&gt;Start from a whiteboard, not a schema&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The current page began as a whiteboard sketch, and the build copied it top to bottom. Two rules shaped it: &lt;strong&gt;phone first&lt;/strong&gt; (every tab checked at 320, 375 and 430 px wide) and &lt;strong&gt;read-only&lt;/strong&gt;. The page never writes to Zoho. Wrong deals get fixed in Zoho, and the page catches up on the next refresh.&lt;/p&gt;
&lt;p&gt;A consultant’s login opens their own page; the admin can switch between consultants or see the whole team. I directed the work, and Claude Code wrote much of the code.&lt;/p&gt;
&lt;h2 id=&quot;what-s-on-the-page&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/#what-s-on-the-page&quot;&gt;&lt;span&gt;What’s on the page&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The target bar.&lt;/strong&gt; Closed, likely to close and still missing for the month, with a line marking the days gone. Under it, a best case (closed plus likely) and a worst case (closed only).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Closing date passed.&lt;/strong&gt; Open deals whose closing date is behind them, in red, with one instruction: move the date in Zoho or close the deal. Left alone, such a deal drops out of every projection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Closing probability.&lt;/strong&gt; Open deals by the consultant’s own call: this month, the next two or three months, or no timing yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your week.&lt;/strong&gt; Demos, proposals and wins since Monday, against the same days last week.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ask for a referral.&lt;/strong&gt; Clients whose deal closed two to four months ago.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architects and designers.&lt;/strong&gt; Partners named on the consultant’s deals, with the quiet ones (a past win, nothing in 90 days) marked as worth a call.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;call-these-first&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/#call-these-first&quot;&gt;&lt;span&gt;Call these first&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;It’s a ranked list:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;always first:  deals waiting for the advance
then, after 7 days with nothing logged
(no note, call, stage change or demo):
  called for this week or this month
  a fresh demo still waiting for a proposal
  a proposal or a negotiation
  rated 4 or 5
order:  by step, then the bigger deal
card:   top five, the rest one tap away&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each row has a tag saying what to do, such as “Collect the advance” or “Call once more”, and one line on why it’s there. No call buttons, no phone numbers: an earlier Today screen with call and WhatsApp buttons was retired because the BI is a report, not a CRM.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why 7 days?&lt;/strong&gt; For every deal open on 1 June, 1 July and 1 August 2026, we compared how often it was won within two months against how long it had gone with nothing logged. The odds dropped sharply after a week of silence and kept falling. It’s a correlation, not proof that calling saves deals, but a sensible line.&lt;/p&gt;
&lt;h2 id=&quot;the-scoreboard-and-the-review&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/#the-scoreboard-and-the-review&quot;&gt;&lt;span&gt;The scoreboard and the review&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The second tab, Closed, is the scoreboard: won against lost for the period and the one before, split by rating, source, value, the consultant’s call and time. At its foot, a review of lost deals shows every Zoho note on the deal and its lead, and flags the ones worth a second look, such as a note written after the loss (the client may be back). Since 15 Oct, each consultant’s login shows the review for their own deals.&lt;/p&gt;
&lt;h2 id=&quot;what-got-cut&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/#what-got-cut&quot;&gt;&lt;span&gt;What got cut&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;More screens were built than kept: Growth Brief mock-ups, an Explore drill-down, follow-up queues that became the Today screen, an ad-spend screen, a funnel, an Ask chat, and a 3D map tab, rolled back. Each went when it didn’t earn its place; Funnel, Ads and Ask went in one decision, so the effort could go into one page.&lt;/p&gt;
&lt;h2 id=&quot;checking-what-the-phone-shows&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/#checking-what-the-phone-shows&quot;&gt;&lt;span&gt;Checking what the phone shows&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;preview mode&lt;/strong&gt; loads made-up deals under a red “made-up numbers” ribbon, as a consultant or as the admin, so layouts get tested without real data.&lt;/p&gt;
&lt;p&gt;On 16 Oct, Claude Code read about &lt;strong&gt;140&lt;/strong&gt; figures off the rendered page, for the whole team and one consultant over two months, and recomputed each with separate SQL. Every figure matched. The check still caught two small things: a tile and the review below it counting different groups under one label, and a split that silently dropped a deal with no lead date. Both were fixed. The rules behind the numbers are in &lt;a href=&quot;https://www.keenan-pereira.in/blog/one-meaning-per-number/&quot;&gt;One meaning per number&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-i-d-keep&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-phone-page-that-says-who-to-call/#what-i-d-keep&quot;&gt;&lt;span&gt;What I’d keep&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Draw the page before querying anything.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End in an action.&lt;/strong&gt; A ranked list with reasons beats another chart.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stay read-only.&lt;/strong&gt; The CRM stays the one place to fix a deal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cut screens that don’t earn their place&lt;/strong&gt;, even finished ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the rendered page&lt;/strong&gt;, not just the query.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>From sleep loop to portable screensaver</title>
    <link href="https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/" />
    <updated>2026-10-27T11:00:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/</id>
    <summary>Left alone, the showroom TV plays a 22-slide loop that sells the life, not the map. When another app wanted it, it became one HTML file built from the same code, by a script that stops if that code changes shape.</summary>
    <content type="html">&lt;p&gt;A showroom TV has to earn its place when nobody is touching it. Ours goes to sleep after &lt;strong&gt;20 seconds&lt;/strong&gt; without a button press, and sleep is a loop with one job. The first line of its notes says it plainly: “It sells the life, not the map.”&lt;/p&gt;
&lt;h2 id=&quot;what-the-loop-plays&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/#what-the-loop-plays&quot;&gt;&lt;span&gt;What the loop plays&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;One pass is &lt;strong&gt;22 slides&lt;/strong&gt;, about &lt;strong&gt;4½ minutes&lt;/strong&gt;: the AHA logo, all eleven AHA moments in the order of a day, five products (the next five on the next pass), two client testimonials, four buildings with AHA homes, both experience centres with live opening hours, and a QR code to the experiences page.&lt;/p&gt;
&lt;p&gt;It opens on the moment that fits the hour. At 6 pm that’s Grand Welcome, arriving home; at 10 am it’s Goodbye Routine, leaving. On a laptop the loop waits until nothing is open, so a page someone is reading is never swept away. The TV sleeps from anywhere: its viewers have walked off.&lt;/p&gt;
&lt;h2 id=&quot;smooth-on-a-2-gb-tv&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/#smooth-on-a-2-gb-tv&quot;&gt;&lt;span&gt;Smooth on a 2 GB TV&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Slide changes used to stutter on the TV, waiting on the network or the image decoder. Now the next slide’s photo is fetched and &lt;strong&gt;decoded while the current one shows&lt;/strong&gt;. The new photo dissolves in over the old one, which is removed only once covered. A few decoded images stay in hand (the one on screen, the next, and the one a ◀ press would bring back), and older ones are let go so the TV doesn’t run out of memory. The progress line is scaled rather than resized, so the graphics chip has little to redo.&lt;/p&gt;
&lt;h2 id=&quot;building-photos-sorted-by-eye&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/#building-photos-sorted-by-eye&quot;&gt;&lt;span&gt;Building photos, sorted by eye&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Most buildings use their public map photo, which is whatever a visitor uploaded. All &lt;strong&gt;229&lt;/strong&gt; were sorted by eye. &lt;strong&gt;65&lt;/strong&gt; were construction sites, hoardings, street scenes or not the building at all (a shop, a temple, a chai van), and are never shown. &lt;strong&gt;76&lt;/strong&gt; were good enough for the loop. Each verdict is tied to that exact photo, so a photo swapped later is judged afresh instead of inheriting the old verdict.&lt;/p&gt;
&lt;h2 id=&quot;the-same-loop-somewhere-else&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/#the-same-loop-somewhere-else&quot;&gt;&lt;span&gt;The same loop, somewhere else&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;In October the loop was wanted for the AHA camera app, as a screensaver in its web view. Rather than keep a second copy that could drift, a script builds the standalone page from the TV’s own code, copying the loop’s functions, photos, words and styles verbatim, so it plays exactly as the TV does.&lt;/p&gt;
&lt;p&gt;It leaves out the login, the map and the CRM data, so the buildings slide goes: &lt;strong&gt;21 slides&lt;/strong&gt;, about 4 minutes. A tap wakes nothing, because there’s no app underneath; it tells the app showing the page, which usually closes it.&lt;/p&gt;
&lt;p&gt;The script is strict. Every function and style rule it copies must be found exactly once, and so must every edit it makes:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// each [old, new] edit must match exactly once, or the build stops&lt;/span&gt;
pairs&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;forEach&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;a&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; b&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; n &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; text&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;a&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;length &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;n &lt;span class=&quot;token operator&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;fail&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;label &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;: expected to find once, found &#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  text &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; text&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;a&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; b&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the TV’s code changes shape, the build stops and says where, instead of shipping a screensaver that quietly differs.&lt;/p&gt;
&lt;h2 id=&quot;what-the-handover-taught&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/#what-the-handover-taught&quot;&gt;&lt;span&gt;What the handover taught&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The first handover was three files, and the page opened alone as a blank white screen. Now &lt;code&gt;aha-screensaver.html&lt;/code&gt; holds everything in &lt;strong&gt;one file&lt;/strong&gt;, so it plays however it’s opened: from a chat, a download, an app’s own files, or handed to a web view as text.&lt;/p&gt;
&lt;p&gt;Older web views needed two more guards. CSS &lt;code&gt;inset&lt;/code&gt; is unknown before Chrome 87, and without it the whole page would collapse to nothing, so the script writes it out as top, right, bottom and left. And if the page can’t start, with JavaScript off (an Android web view’s default) or files missing, it says so on screen after &lt;strong&gt;two seconds&lt;/strong&gt; instead of staying blank.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-from-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/from-sleep-loop-to-screensaver/#what-i-took-from-it&quot;&gt;&lt;span&gt;What I took from it&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Idle time is airtime.&lt;/strong&gt; A screen nobody is touching should still be selling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decode before you show.&lt;/strong&gt; A change should never wait on the network or the decoder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generate the copy, don’t fork it.&lt;/strong&gt; And make the generator fail loudly when its source moves.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hand over one file.&lt;/strong&gt; Assume it will be opened in the least helpful way.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I directed the work and Claude Code wrote much of the code. There is still only one loop: the screensaver is rebuilt from it, and the build stops rather than copy something that has changed shape.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The slow part was the server, not the page</title>
    <link href="https://www.keenan-pereira.in/blog/the-slow-part-was-the-server/" />
    <updated>2026-10-27T11:20:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/the-slow-part-was-the-server/</id>
    <summary>PageSpeed said AHA’s pages were slow for real users, yet 96% of the wait came before anything painted. How I told server time from page time, found a hero image that never existed, and tested every page on nine browser setups.</summary>
    <content type="html">&lt;p&gt;PageSpeed Insights said three of AHA’s pages failed Core Web Vitals on phones. My own throttled lab runs of the same pages passed comfortably. Both were right, and the gap between them was the most useful number I found.&lt;/p&gt;
&lt;h2 id=&quot;do-the-arithmetic-before-you-optimise&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/the-slow-part-was-the-server/#do-the-arithmetic-before-you-optimise&quot;&gt;&lt;span&gt;Do the arithmetic before you optimise&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;PageSpeed shows real-user (field) data beside its lab test. On all three pages it told one story: first paint at &lt;strong&gt;2.4 s&lt;/strong&gt;, largest paint at &lt;strong&gt;2.5 s&lt;/strong&gt;. About &lt;strong&gt;96% of the time to Largest Contentful Paint (LCP)&lt;/strong&gt; passed before anything was drawn at all.&lt;/p&gt;
&lt;p&gt;That rules out the usual suspects. Image weight, render-blocking CSS and image priority only matter once bytes arrive. Field Time to First Byte (TTFB) said the same: about &lt;strong&gt;1.2 s&lt;/strong&gt; waiting for the server. My lab runs, on Slow 4G with a 4× CPU throttle, measured TTFB at 36 to 108 ms. Real users were losing about a second before the race started.&lt;/p&gt;
&lt;p&gt;Interaction delay agreed. Real-user INP was 247 to 270 ms, yet under the same throttle the page’s tap handlers took 14 ms. A page still arriving when people start tapping explains the difference.&lt;/p&gt;
&lt;p&gt;The pages were already lean: one script, 145 to 171 KB, the main image preloaded. Hosting was outside the project’s scope. I recorded the finding as a measurement, not a proposal, so nobody commissions more page work against a problem it can’t move.&lt;/p&gt;
&lt;p&gt;The lab sweep did find slow pages, just not these. Their causes were in the page, and fixing them took &lt;code&gt;/solutions&lt;/code&gt; from 5,440 to 1,108 ms. That’s &lt;a href=&quot;https://www.keenan-pereira.in/blog/hero-image-waited-in-line/&quot;&gt;its own story&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-hero-image-that-never-existed&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/the-slow-part-was-the-server/#the-hero-image-that-never-existed&quot;&gt;&lt;span&gt;The hero image that never existed&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;On 9 October I went through the site on my iPhone, and the homepage video seemed to load late.&lt;/p&gt;
&lt;p&gt;The August audit had flagged that the 5.25 MB hero video had no poster frame. By October the template referenced one, and even preloaded it at high priority, but the file had never been uploaded. It returned &lt;strong&gt;403&lt;/strong&gt;. On this hosting a missing file answers 403, not 404, so it reads like a permissions error.&lt;/p&gt;
&lt;p&gt;Two details made it worse. The lab test still passed: with no poster, it measured the headline as the LCP element, so a broken hero scored as fast. And Low Power Mode on iPhone, like data-saver modes, blocks autoplay. For those visitors the poster is the hero, permanently, and it was a blank box.&lt;/p&gt;
&lt;p&gt;Our first guess, that the video took 20 seconds or more on mobile data, was wrong. On a throttled phone it was playing at 3.0 s, because the CDN streams it progressively.&lt;/p&gt;
&lt;p&gt;The fix was small: point the poster and its preload at a photo that exists, verified 200. The real gap was in tooling: the build’s image guard checked each image’s hostname, never whether the file existed.&lt;/p&gt;
&lt;h2 id=&quot;then-test-every-page-everywhere&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/the-slow-part-was-the-server/#then-test-every-page-everywhere&quot;&gt;&lt;span&gt;Then test every page, everywhere&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Phones are AHA’s bigger audience, so I asked for every page to work on iPhone, Android and every major browser. With Claude Code I built a Playwright sweep of all &lt;strong&gt;110 pages&lt;/strong&gt; in &lt;strong&gt;9 browser setups&lt;/strong&gt;: Chrome, Edge, Firefox and Safari’s engine, on phones, a tablet and desktops. A page is clean only with no JavaScript errors, no failed requests, no sideways scroll, nothing stuck invisible, no broken images or empty sliders, one H1, and no form field under 16 px, which makes iPhone Safari zoom in.&lt;/p&gt;
&lt;p&gt;Every setup came back &lt;strong&gt;110 of 110&lt;/strong&gt;. The pass also fixed slider drag in Firefox, where the cards are links and Firefox started its own link drag, and added CSS prefixes Safari needs.&lt;/p&gt;
&lt;p&gt;A phone pass added interaction checks. At 320 px wide, &lt;strong&gt;26 of 31&lt;/strong&gt; passed; after the fixes, &lt;strong&gt;31 of 31&lt;/strong&gt; on all five phones. One fix was for a real bug: on short phones the floating call and WhatsApp buttons sat under the open menu, so a tap meant to close it could start a call.&lt;/p&gt;
&lt;h2 id=&quot;results-lab&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/the-slow-part-was-the-server/#results-lab&quot;&gt;&lt;span&gt;Results (lab)&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Homepage Lighthouse, median of 13 runs: Performance &lt;strong&gt;0.85 to 0.89&lt;/strong&gt;, Accessibility &lt;strong&gt;0.96 to 1.00&lt;/strong&gt;, failing tap targets &lt;strong&gt;37 to 0&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Smaller copies of seven oversized photos, served through &lt;code&gt;srcset&lt;/code&gt;, are &lt;strong&gt;73% lighter&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Width and height on &lt;strong&gt;626 of 626&lt;/strong&gt; images, up from 226. Lighthouse across all 74 live pages in September: median score 83, CLS &lt;strong&gt;0.000&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There’s no real-user Core Web Vitals data from after these fixes yet, so these are lab figures.&lt;/p&gt;
&lt;h2 id=&quot;rules-i-kept&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/the-slow-part-was-the-server/#rules-i-kept&quot;&gt;&lt;span&gt;Rules I kept&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;If first paint and largest paint are close, look at the server.&lt;/strong&gt; No image fix will move it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A passing metric can hide a broken page.&lt;/strong&gt; Check what was measured.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check that files exist, not just where they point.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test every page on every device.&lt;/strong&gt; Samples miss the page that breaks.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>A nightly sync that survives its own failures</title>
    <link href="https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/" />
    <updated>2026-10-27T11:40:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/</id>
    <summary>One expired token shouldn’t hold back a day of CRM data. How a 25-step sync decides which failures stop the run and which only warn, and the 28 checks that grade every run.</summary>
    <content type="html">&lt;p&gt;Every screen in the growth-intelligence platform reads from one Postgres database, fed by a daily sync that talks to Zoho CRM, Google Ads, Meta Ads, Google’s geocoder and Claude. On any given day, one of them can fail. The real question was what the other steps should do when that happens.&lt;/p&gt;
&lt;p&gt;Claude Code wrote most of the scripts. My job was deciding how each one is allowed to fail.&lt;/p&gt;
&lt;h2 id=&quot;one-script-25-steps&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/#one-script-25-steps&quot;&gt;&lt;span&gt;One script, 25 steps&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The orchestrator runs &lt;strong&gt;25 steps&lt;/strong&gt; in order: leads and deals through Zoho’s Bulk Read API, demos and lead notes, call recordings and electrical-plan PDFs, Google and Meta ads, AI summaries and call analysis, geocoding, enrichment, and a quality check at the end. From 27 April a GitHub Action scheduled it for &lt;strong&gt;08:00 IST&lt;/strong&gt; every day, and it also shipped as a nightly cron job.&lt;/p&gt;
&lt;p&gt;If all 25 had to succeed, one flaky API would hold back the rest. So every step is one of two kinds.&lt;/p&gt;
&lt;h2 id=&quot;two-kinds-of-failure&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/#two-kinds-of-failure&quot;&gt;&lt;span&gt;Two kinds of failure&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Core (9 steps):&lt;/strong&gt; the leads, deals and demos pulls, recordings and plan PDFs, the OCR that reads those PDFs, geocoding and the buildings rebuild. Everything downstream reads from them, so if one fails, the run stops and writes “failed” and the step’s name to a status row.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optional (16 steps):&lt;/strong&gt; ads, AI summaries, call analysis, enrichment and the quality check, among others. These are marked non-fatal: a failure logs a warning and the run moves on. An expired ads token is no reason to hold back the CRM data.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; step &lt;span class=&quot;token keyword&quot;&gt;of&lt;/span&gt; steps&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;step&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;err&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;step&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;nonFatal&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;warn&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;step&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;continue&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;// log it, keep going&lt;/span&gt;
    &lt;span class=&quot;token function&quot;&gt;writeStatus&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;failed&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; step&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;exit&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;     &lt;span class=&quot;token comment&quot;&gt;// stop, and say where&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token function&quot;&gt;writeStatus&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;success&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every main page shows a freshness badge read from that status row: green under &lt;strong&gt;12 hours&lt;/strong&gt;, amber up to &lt;strong&gt;36&lt;/strong&gt;, red after that or when the last run failed. Hovering over it shows where a failed run stopped.&lt;/p&gt;
&lt;h2 id=&quot;failing-small-inside-a-step&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/#failing-small-inside-a-step&quot;&gt;&lt;span&gt;Failing small inside a step&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Attachments.&lt;/strong&gt; The recording and plan-PDF syncs re-check every attachment on every run instead of keeping a cursor. It costs more, but it heals itself: a file missed yesterday lands today. Each file is handled on its own, so one bad attachment can’t halt the scan, and a retry overwrites instead of leaving an orphan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Meta Ads.&lt;/strong&gt; Meta rejects large day-by-day requests, so the puller asks in &lt;strong&gt;30-day&lt;/strong&gt; chunks. A failed chunk is logged and skipped, and the rest still lands.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pinned windows.&lt;/strong&gt; A normal run re-pulls only the last &lt;strong&gt;7 days&lt;/strong&gt; of Google Ads and &lt;strong&gt;28 days&lt;/strong&gt; of Meta, because Meta keeps attributing late conversions to past days. The windows are pinned in the orchestrator, so a backfill setting left in a config file can’t turn a daily run into a full-history re-pull.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;a-check-that-grades-every-run&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/#a-check-that-grades-every-run&quot;&gt;&lt;span&gt;A check that grades every run&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The last step runs &lt;strong&gt;28 checks&lt;/strong&gt;: totals that shouldn’t collapse; records missing IDs, statuses, sources or coordinates; drops in leads, deals or demos since the previous run; and stored files reconciled against table rows, so a recording without a row, or a row pointing at nothing, shows up straight away. It grades the run; it doesn’t block it.&lt;/p&gt;
&lt;p&gt;The log keeps the last &lt;strong&gt;30 runs&lt;/strong&gt;. Across those, from 12 August to 9 October, &lt;strong&gt;93%&lt;/strong&gt; of checks passed. The only flags were the same two every time: leads with no status, and demos with no lead source. Both are CRM data-entry gaps rather than sync failures, so the check keeps them visible until someone fills them in.&lt;/p&gt;
&lt;h2 id=&quot;what-clean-pulls-exposed&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/#what-clean-pulls-exposed&quot;&gt;&lt;span&gt;What clean pulls exposed&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Getting the ads pullers right exposed an older problem. The ROAS page had been reading spend from an abandoned table, and its spend was badly inflated. Once the Meta puller could backfill history in chunks, I checked that it covered every ad the old table had, then pointed the spend views at it. It was a database-only change, and those totals now refresh with every run.&lt;/p&gt;
&lt;h2 id=&quot;rules-i-d-keep&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/a-nightly-sync-that-survives-its-own-failures/#rules-i-d-keep&quot;&gt;&lt;span&gt;Rules I’d keep&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Decide per step what failure means.&lt;/strong&gt; Stop for data everything else depends on; warn for the rest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail small.&lt;/strong&gt; One bad file, chunk or token should cost one item, not the run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin windows in code&lt;/strong&gt;, not in a config file someone forgot to reset.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grade every run and keep the history.&lt;/strong&gt; A flag that appears every time is a process problem, not a bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Show freshness on every page&lt;/strong&gt;, so nobody has to ask whether the data is current.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A sync is only as good as its schedule. By October the daily run had stopped and one puller was swallowing its own errors, which is why the &lt;a href=&quot;https://www.keenan-pereira.in/work/consultant-dashboard/&quot;&gt;AI growth agent&lt;/a&gt; later got a live warehouse of its own, refreshed every 15 minutes.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Giving a TV remote a mouse pointer</title>
    <link href="https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/" />
    <updated>2026-10-27T12:00:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/</id>
    <summary>The showroom map needed free movement, but a TV remote has four arrows and OK. A pointer that eases in, curves between arrows and settles onto buildings made it work like a mouse.</summary>
    <content type="html">&lt;p&gt;The showroom app’s &lt;strong&gt;Map&lt;/strong&gt; tab puts every building on one map you move around freely: no rail, no lists, from the whole city down to street level. On a laptop that means a mouse and a scroll wheel. On the showroom TV it means a remote with four arrows and OK, and an air mouse that wobbles. (The TV’s other constraints are in &lt;a href=&quot;https://www.keenan-pereira.in/blog/building-for-a-2gb-tv/&quot;&gt;Building for a 2 GB TV&lt;/a&gt;.) Claude Code wrote most of the code; my job was deciding how it should feel.&lt;/p&gt;
&lt;h2 id=&quot;move-the-pointer-not-the-map&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/#move-the-pointer-not-the-map&quot;&gt;&lt;span&gt;Move the pointer, not the map&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The arrows move a &lt;strong&gt;pointer&lt;/strong&gt; over the map, the way a mouse would, and the map stays still. Push the pointer against the edge of the screen and the map scrolls, so it can still travel the whole city. That gives the remote what a mouse has: a place you are pointing at.&lt;/p&gt;
&lt;h2 id=&quot;four-arrows-that-feel-like-a-hand&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/#four-arrows-that-feel-like-a-hand&quot;&gt;&lt;span&gt;Four arrows that feel like a hand&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A tap nudges it.&lt;/strong&gt; Holding an arrow starts it gently, then speeds it up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two arrows held together go diagonally.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Turns curve.&lt;/strong&gt; Some remotes only press one arrow at a time. Switching from one arrow to another curves round instead of turning a sharp corner, so even those remotes can go diagonally.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The curve falls out of smoothing each axis on its own. Speed follows a held arrow quickly, lets go of a released one slowly while another is still held, and stops briskly once nothing is:&lt;/p&gt;
&lt;pre class=&quot;language-js&quot;&gt;&lt;code class=&quot;language-js&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// every frame, for x and y separately&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; target &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;held&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;axis&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; dir &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;speed&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;timeHeld&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// gentle start, then faster&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; lag &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;held&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;axis&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;QUICK&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;anyArrowHeld&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;SLOW&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;BRISK&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
v&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;axis&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;target &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; v&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;axis&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; Math&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;exp&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt;dt &lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt; lag&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And at the end of a move, &lt;strong&gt;let go right beside a building and the pointer settles onto it.&lt;/strong&gt; Nobody has to land exactly on a dot.&lt;/p&gt;
&lt;h2 id=&quot;finding-a-building-without-aiming&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/#finding-a-building-without-aiming&quot;&gt;&lt;span&gt;Finding a building without aiming&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The building at the pointer &lt;strong&gt;turns gold&lt;/strong&gt;, and a card shows its photo, area, homes and plans. The card sits in a corner and moves across when the pointer heads into it.&lt;/li&gt;
&lt;li&gt;Buildings are &lt;strong&gt;named as you come close&lt;/strong&gt;: the ones with AHA homes first, then every building, never overlapping and never under the card or the key hints.&lt;/li&gt;
&lt;li&gt;On a near tie, &lt;strong&gt;an AHA home wins&lt;/strong&gt;. It’s what visitors come to see.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OK always does something.&lt;/strong&gt; It opens the building once it stands clear or the map is close in. Before that, it goes one level closer around the pointer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BACK retraces.&lt;/strong&gt; It steps out one level at a time to the whole city, then closes the map. Open a building and come back, and you’re at &lt;strong&gt;exactly the same spot and zoom&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-air-mouse-wobbles&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/#the-air-mouse-wobbles&quot;&gt;&lt;span&gt;The air mouse wobbles&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The TV also has an air mouse, so the map takes a drag (a flick carries on), a scroll to zoom, a hover for a building’s card and a click to open it.&lt;/p&gt;
&lt;p&gt;An air mouse moves a little as its button is pressed, so a click can turn into a drag by accident. On the TV, a press has to travel &lt;strong&gt;three times as far&lt;/strong&gt; as on a laptop before it counts as a drag. The map moves only while the button is actually held down, so a button-up the browser never passed on can’t leave the map stuck to the pointer. Put the air mouse down and pick up the arrows, and the pointer carries on from where the cursor was.&lt;/p&gt;
&lt;h2 id=&quot;keeping-it-crisp&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/#keeping-it-crisp&quot;&gt;&lt;span&gt;Keeping it crisp&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The camera is driven by the app, not by Google’s own gestures, which misread the double-size map the app draws for sharpness. Every step in or out is a whole zoom level from the opening view, so the map stays crisp at each stop.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-from-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/giving-a-tv-remote-a-pointer/#what-i-took-from-it&quot;&gt;&lt;span&gt;What I took from it&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Model the hand, not the keys.&lt;/strong&gt; Ease in, curve, settle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make every press useful.&lt;/strong&gt; OK zooms until it can open.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give hardware slack.&lt;/strong&gt; An air mouse is not a mouse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BACK should retrace.&lt;/strong&gt; One level at a time, back to the same spot.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>Turning showroom pages into local landing pages</title>
    <link href="https://www.keenan-pereira.in/blog/showroom-pages-into-local-landing-pages/" />
    <updated>2026-10-27T12:20:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/showroom-pages-into-local-landing-pages/</id>
    <summary>AHA’s two experience-centre pages were thin, Google had never crawled one of them, and three pages chased the same Mumbai query. How each page got one clear job.</summary>
    <content type="html">&lt;p&gt;AHA has two experience centres in Mumbai, in Santacruz West and Lower Parel, where buyers can try a smart home before they commit to one. In search, the centres already did more than the website: on commercial queries, the Santacruz Google listing averaged around position 3, while the site’s own sales pages barely ranked. On the website itself, both centres had pages that barely made the case.&lt;/p&gt;
&lt;h2 id=&quot;what-i-found-in-october&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/showroom-pages-into-local-landing-pages/#what-i-found-in-october&quot;&gt;&lt;span&gt;What I found in October&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Thin, shared copy.&lt;/strong&gt; The two centre pages had &lt;strong&gt;332 and 362 words&lt;/strong&gt;, from one shared text. Their schema described a web page and the company, but neither carried a LocalBusiness of its own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A page Google didn’t know.&lt;/strong&gt; The Lower Parel page had been live since late September. Google had never crawled it, and URL Inspection called it “URL is unknown to Google”.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A button to nowhere.&lt;/strong&gt; “Book a Demo” on both pages pointed at a form the template never included.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Three pages, one query.&lt;/strong&gt; The homepage (title and H1), the About page (title) and the Mumbai page all targeted “home automation mumbai”.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-centre-pages&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/showroom-pages-into-local-landing-pages/#the-centre-pages&quot;&gt;&lt;span&gt;The centre pages&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Each centre got its own copy, approved before building: &lt;strong&gt;897 words&lt;/strong&gt; for Santacruz West and &lt;strong&gt;999&lt;/strong&gt; for Lower Parel. Santacruz covers its colour scenes and what a system costs. Lower Parel covers the hardware you can press, from the door lock to the curtain track, and what happens after a visit.&lt;/p&gt;
&lt;p&gt;Each page now has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Visit details as facts:&lt;/strong&gt; address, hours, walk-ins welcome, about 60 minutes, free, parking.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nearby areas.&lt;/strong&gt; “Who Lower Parel is closest to” names Worli, Prabhadevi, Dadar and the rest of south Mumbai. I left named buildings off, because their distance to a centre isn’t on record.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Five visible FAQs&lt;/strong&gt;, with FAQPage schema built from the same strings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The lead form&lt;/strong&gt;, restored.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One LocalBusiness per centre&lt;/strong&gt; (a HomeAndConstructionBusiness). Each is built once in &lt;code&gt;centres.js&lt;/code&gt; and printed by both the centre page and the contact page under the same &lt;code&gt;@id&lt;/code&gt;, so the two can’t disagree. Its name, location and map link match the Google listing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same afternoon the layout was redone to read well on a phone: visit facts in tiles, nearby areas as chips, and photo cards for the products you can try. As with the rest of the site, I directed the work and Claude Code wrote much of the code.&lt;/p&gt;
&lt;h2 id=&quot;the-mumbai-page&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/showroom-pages-into-local-landing-pages/#the-mumbai-page&quot;&gt;&lt;span&gt;The Mumbai page&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The Mumbai page was rebuilt on 17 September: an answer in its first 44 words, question-shaped headings, a comparison table, and links to the live Google listing in place of a hand-typed “5.0 on Google”. When a national “companies” cluster needed a page the same day, it got an India-wide buyer’s guide, not a second Mumbai page competing with one rebuilt six hours earlier.&lt;/p&gt;
&lt;p&gt;On 22 October it closed five gaps against the pages that outrank it: neighbourhoods served, a written warranty, Alexa and Google Home, a dated Google rating in visible text only, and a cost calculator that opens on the same complete 3 BHK as the headline estimate.&lt;/p&gt;
&lt;p&gt;That calculator had a bug. Its four weights summed to &lt;code&gt;0.9999999999999999&lt;/code&gt; in floating point, so the script always rounded down and replaced the figure in the HTML on load. The HTML said one range; visitors with JavaScript saw a lower one. The fix was two lines: round the sum, then round to whole lakh.&lt;/p&gt;
&lt;p&gt;Then the overlap. I chose to let the Mumbai page own the query. The homepage title became brand-led, the About title stepped back, and the homepage now links to the Mumbai page in its first paragraph. That has a cost I accepted: the homepage is the page Bing’s AI answers cite most, and “Mumbai” left its title and H1.&lt;/p&gt;
&lt;h2 id=&quot;where-it-stands&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/showroom-pages-into-local-landing-pages/#where-it-stands&quot;&gt;&lt;span&gt;Where it stands&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Since Google swapped the URLs on 13 October, the Mumbai page has ranked &lt;strong&gt;2 to 4&lt;/strong&gt; for “home automation company in mumbai” and two close variants. The homepage only ever reached 7 to 17 on them.&lt;/li&gt;
&lt;li&gt;The 22 October work is built, verified and waiting on the next upload. Then Lower Parel gets an indexing request.&lt;/li&gt;
&lt;li&gt;Rankings aren’t enquiries. By 22 October, commercial search as a whole hadn’t moved.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rules-for-local-pages&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/showroom-pages-into-local-landing-pages/#rules-for-local-pages&quot;&gt;&lt;span&gt;Rules for local pages&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;One query, one page.&lt;/strong&gt; Pick the owner, and have every other page link to it instead of competing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One place, one entity.&lt;/strong&gt; Build each location’s schema once and print it everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish only what’s on record.&lt;/strong&gt; Areas, scenes and ratings come from records, not memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check what your JavaScript does to the HTML.&lt;/strong&gt; It can quietly contradict the page it runs on.&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
  <entry>
    <title>One meaning per number, so the screens stop arguing</title>
    <link href="https://www.keenan-pereira.in/blog/one-meaning-per-number/" />
    <updated>2026-10-27T12:40:00Z</updated>
    <id>https://www.keenan-pereira.in/blog/one-meaning-per-number/</id>
    <summary>Two tabs of the same BI disagreed on the same month, and neither had a bug. Writing each rule down once, with its date, and checking every copy against it fixed that.</summary>
    <content type="html">&lt;p&gt;In mid-October, two tabs of AHA’s sales BI showed the same September, and one total was &lt;strong&gt;more than twice&lt;/strong&gt; the other. Neither tab had a bug. Each was following its own rule, and the rules had drifted apart.&lt;/p&gt;
&lt;h2 id=&quot;one-number-three-hidden-choices&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/one-meaning-per-number/#one-number-three-hidden-choices&quot;&gt;&lt;span&gt;One number, three hidden choices&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Each tab had picked up its own answer to three questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Which date?&lt;/strong&gt; Overview put a win on the deal’s closing date. Closed put it on the day someone marked it won in Zoho.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which amount?&lt;/strong&gt; One showed amounts after GST. The other showed them as stored in Zoho, with 18% GST included.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which people?&lt;/strong&gt; One counted the consulting team. The other counted every consultant.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It wasn’t only the tabs. On 22 Sep it was decided that a deal still waiting for its advance (“Closed and Advance Pending”) is not a win. Two weeks later, the database views and the written instructions behind the Ask chat still counted it as one.&lt;/p&gt;
&lt;p&gt;Even the words drifted. In conversation, “taxed” meant the amount with 18% GST taken off. The BI labelled that same figure “untaxed”.&lt;/p&gt;
&lt;h2 id=&quot;write-each-rule-once-with-its-date&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/one-meaning-per-number/#write-each-rule-once-with-its-date&quot;&gt;&lt;span&gt;Write each rule once, with its date&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The fix was one home for every definition, each filed under the date it was decided:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;SQL          call rating 3 or higher, from any source
HQ           rating 4 or 5, always counted inside SQL
CPL          channel spend / all leads in that channel’s bucket
Google       includes WhatsApp-direct and Exotel leads
Win          stage Closed Won, dated by its closing date
Consultant   the deal’s Lead Assigned To
Amounts      after 18% GST is deducted, and labelled so&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those rules live in a compact context file that Claude Code, which wrote much of the code, reads at the start of every session, before any analysis or build. The same file says which source wins for each kind of number, lists every known data defect and what to do about it, files decisions by date, and keeps open questions with the default in use until they’re answered. A separate glossary holds &lt;strong&gt;750&lt;/strong&gt; terms.&lt;/p&gt;
&lt;p&gt;Most rules have a story. CPL had three candidate denominators (paid-ad leads only, every lead in the channel’s bucket, or the platform’s own count) until one was picked. The consultant rule changed when the BI and a consultant’s own saved filter in Zoho disagreed about their open deals: the BI credited whoever ran the demo, and the filter used Lead Assigned To. The record owner is never used, because it’s the same person on most records.&lt;/p&gt;
&lt;h2 id=&quot;why-the-date-rule-matters&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/one-meaning-per-number/#why-the-date-rule-matters&quot;&gt;&lt;span&gt;Why the date rule matters&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;45%&lt;/strong&gt; of 2026 wins were marked won in Zoho after their closing date. For nearly half the wins the two dates differ, and the choice can move a win into another month. Marking also happens in batches: one clean-up day in 2025 marked dozens of wins at once.&lt;/p&gt;
&lt;p&gt;Neither date is perfect, and a closing date can be backdated. The closing date won because it matches the wins sheet that already existed, and since 15 Oct every tab uses it.&lt;/p&gt;
&lt;h2 id=&quot;put-the-rules-where-people-look&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/one-meaning-per-number/#put-the-rules-where-people-look&quot;&gt;&lt;span&gt;Put the rules where people look&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A rule in a file only helps people who open the file. So the BI has an admin panel, Fields &amp;amp; formulas, with &lt;strong&gt;54&lt;/strong&gt; metric formulas in plain words, &lt;strong&gt;104&lt;/strong&gt; field meanings, &lt;strong&gt;33&lt;/strong&gt; traps to avoid when reading Zoho and all &lt;strong&gt;573&lt;/strong&gt; Zoho fields, behind one search box. When a number looks odd, its definition is in the same app.&lt;/p&gt;
&lt;p&gt;Labels carry rules too. Amounts say “18% GST deducted”, not a word that can be read both ways.&lt;/p&gt;
&lt;h2 id=&quot;keep-the-copies-honest&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/one-meaning-per-number/#keep-the-copies-honest&quot;&gt;&lt;span&gt;Keep the copies honest&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A rule still lives in several copies: the database views, the panel and the instructions written for the Ask chat. Check 9 of the data audit compares every copy with the dated decisions on each change to the BI or its rules, and passes only when they match everywhere. The audit’s other checks, and the bugs they caught, are in &lt;a href=&quot;https://www.keenan-pereira.in/blog/five-bugs-a-data-audit-caught/&quot;&gt;Five bugs a data audit caught&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;rules-i-now-keep-for-every-metric&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://www.keenan-pereira.in/blog/one-meaning-per-number/#rules-i-now-keep-for-every-metric&quot;&gt;&lt;span&gt;Rules I now keep for every metric&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A number is a definition, a date, an amount basis and a group of people.&lt;/strong&gt; Write all four down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One home per rule, with its date.&lt;/strong&gt; Everything else is a copy, and copies get checked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Labels say what was done.&lt;/strong&gt; “18% GST deducted” can’t be misread. “Untaxed” was.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When two screens disagree, compare definitions before data.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The numbers didn’t get bigger. They just stopped changing with the tab you opened.&lt;/p&gt;
</content>
  </entry>
</feed>