<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Hasan Yahşi · Notes</title>
    <link>https://hasanyahsi.com/notes/</link>
    <atom:link href="https://hasanyahsi.com/feed.xml" rel="self" type="application/rss+xml" />
    <description>Short technical notes: what was tried, what was measured, what went wrong.</description>
    <language>en-US</language>
    <lastBuildDate>Tue, 15 Sep 2026 06:00:00 GMT</lastBuildDate>
    <item>
      <title>paintcheck: what two wrong versions taught me about measuring contrast from pixels</title>
      <link>https://hasanyahsi.com/notes/paintcheck-method/</link>
      <guid isPermaLink="true">https://hasanyahsi.com/notes/paintcheck-method/</guid>
      <pubDate>Tue, 15 Sep 2026 06:00:00 GMT</pubDate>
      <description>The idea was simple, screenshot the page and read the colours. The first two implementations were confidently wrong in opposite directions. What the fixtures caught, and the method that held.</description>
      <content:encoded><![CDATA[<p><a href="https://github.com/hasanyahsitr/paintcheck">paintcheck</a> measures text contrast from the pixels Chrome painted instead of from CSS. This note is about the part that is not in the README: how the method went wrong before it went right. Everything below is reconstructed from the repository&#39;s changelog and test fixtures, not from memory.</p>
<h2>Why CSS was not enough</h2>
<p>Tools like axe-core compute contrast from <code>color</code> and <code>background-color</code>. On my own site the text sat on a photograph under a gradient scrim; there was no background colour to read, so the checkers reported the element as <em>incomplete</em>. The real ratio at the worst point was about 3:1 against a 4.5:1 requirement, and nothing had told me.</p>
<h2>Attempt one: sample next to the text</h2>
<p>The first version took a screenshot and read the background colour from pixels beside each glyph. On flat backgrounds this works. On a gradient the pixel beside a letter is a different colour from the pixel under it, so the readings were wrong in both directions: false alarms on passing text and clean results on failing text. I noticed because the numbers did not match what I could see.</p>
<h2>Attempt two: estimate coverage from change</h2>
<p>The second version took two screenshots, one normal and one with all text hidden, and estimated how much each pixel was covered by a glyph from how much it changed. Then it read the colours from the most-covered pixels.</p>
<p>On a gradient the change between the two screenshots scales with background darkness. The estimate therefore selected pixels only in the highest-contrast region and never looked at the light end. The fixture <code>gradient.html</code>, where the background crosses the text&#39;s own colour, has a worst point near 1.1:1. This version reported 4.47:1 and passed it. The test failed; I had not.</p>
<h2>The method that held</h2>
<p>Five screenshots of the same viewport:</p>
<ul>
<li><strong>A</strong>: the page as rendered</li>
<li><strong>A2</strong>: 300 ms later; pixels that changed are excluded (fonts still loading, animations)</li>
<li><strong>B</strong>: all text forced transparent</li>
<li><strong>C</strong>: all text forced black</li>
<li><strong>D</strong>: all text forced white</li>
</ul>
<p>C and D give the coverage of each pixel exactly. If a pixel is covered by a fraction α over background <code>bg</code>, then C = (1−α)·bg and D = α·255 + (1−α)·bg, so α = (D − C)/255, independent of the background and of the text&#39;s real colour. Pixels near the element&#39;s maximum α are the glyph core; antialiased edges are dropped.</p>
<p>The foreground is then read from A at those pixels and the background from B at the same pixels. Nothing is computed. Opacity chains, rgba colours and pseudo-element scrims were already applied by Chrome when it painted.</p>
<h2>Two corrections the suite forced afterwards</h2>
<p><strong>Subpixel antialiasing.</strong> On Windows, ClearType gives each colour channel a different coverage. Pixels treated as fully covered were still blends, and every reading came out about half a point low: 3.92 measured against 4.48 computed for <code>#777</code> on white. Chrome is now launched with greyscale antialiasing (<code>--disable-lcd-text</code>).</p>
<p><strong>Thin glyphs.</strong> Where a stem is narrower than a pixel, no pixel is fully covered, so even the densest one is a mixture. Since α is known the mixture can be undone: E = B + (A − B)/α. Without it, 12 px footer links at a real 5.85:1 came out as 3.81:1. The first version of this correction also divided the colour&#39;s own alpha out of the coverage, which is wrong: the black and white probes override the colour but not the element&#39;s <code>opacity</code>, so only opacity belongs in that division. An rgba .68 line that must pass and an rgba .5 line that must fail at 3.32:1 now pin both directions.</p>
<h2>What is verified and what is not</h2>
<p>The recorded 57-test run for revision 37bbe2a passed locally on Windows 11 with Chrome 152. The benchmark runs axe-core and paintcheck on the same fixtures, using calculated reference ratios for flat backgrounds. Photo and scrim fixtures have no hand-computed reference value. These results describe the local revision, which has not been published to GitHub.</p>
<p>Update, 16 September 2026: revision 37bbe2a is now on GitHub and its CI run on Ubuntu with Node 20/22 passed. macOS is still untested. Font rendering can change measured ratios; cross-machine pass/fail stability has not been established beyond these two environments.</p>
<p>The tool is not on npm yet. It runs from the repository with <code>npx github:hasanyahsitr/paintcheck</code> and needs a local Chrome.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Catching a contrast failure on my own site with paintcheck</title>
      <link>https://hasanyahsi.com/notes/measuring-my-own-site/</link>
      <guid isPermaLink="true">https://hasanyahsi.com/notes/measuring-my-own-site/</guid>
      <pubDate>Tue, 15 Sep 2026 06:00:00 GMT</pubDate>
      <description>paintcheck found 12 contrast findings on the new home page: the accent blue I copied from a design reference measures 4.09:1 on white. Lighthouse also surfaced 26 prefetch 404s from a Next.js 16 static export quirk. Both fixed, both measured again.</description>
      <content:encoded><![CDATA[<p>The new hasanyahsi.com went live in a bento layout modelled on a portfolio template I liked. Before publishing I ran the two checks I tell other people to run: paintcheck (my own contrast tool) and Lighthouse. Both found something.</p>
<h2>paintcheck: the accent colour</h2>
<p>The reference design uses a bright blue, <code>#4770ff</code>, for links and buttons on white cards. It looks fine. Measured from the rendered pixels it is not fine:</p>
<pre><code>! [contrast] section.hy-kart &gt; div.hy-kart-bas &gt; a.hy-ok
    &quot;More About Me&quot;
    16px — 4.09:1 — needs 4.5:1
    rgb(73,114,255) on rgb(255,255,255)
! [contrast] div.hy-profil-dugmeler &gt; a.hy-dugme
    &quot;Write to me&quot;
    16px — 4.16:1 — needs 4.5:1
    rgb(254,254,255) on rgb(71,112,255)
</code></pre>
<p>Twelve findings on the home page, six on the about page, six on the contact page. All the same cause: blue text on white, or white text on that blue, at 4.09 to 4.18 to 1 against a 4.5 to 1 threshold. This is the flat-background case where CSS arithmetic is exact, so axe-core would have caught it too. I simply had not run anything yet.</p>
<p>Fix: the accent became <code>#3457e8</code> for text and button backgrounds in the light theme (5.8:1 on white), and a lighter <code>#6b8cff</code> for text on the black cards in the dark theme. Visually the difference is small. After the change paintcheck reports zero contrast findings on all four pages. The remaining findings are <code>target-size</code> notes on the visually hidden radio inputs inside labelled chips; the label is the click target, the input is 1×1 by design.</p>
<h2>Lighthouse: 26 requests to files that do not exist</h2>
<p>Desktop Lighthouse on the home page: performance 96, accessibility 96, best practices 96, SEO 100. The &quot;errors in console&quot; audit listed ten 404s during the run, and a puppeteer script that hovers the links counted 26 on a full visit.</p>
<p>The requests looked like <code>/about/__next.!KGVuKQ.about.__PAGE__.txt</code>. Next.js 16.2 static export writes segment prefetch payloads as nested folders, <code>out/about/__next.!KGVuKQ/about/__PAGE__.txt</code>, while the client asks for the dotted, flat name. Every prefetch failed and every client-side navigation fell back to a full page load.</p>
<p>I did not find a config switch for this, so the build now runs a 30-line script that copies each nested payload to its flat name. After that: zero 404s, client navigation keeps the trailing slash, and Lighthouse desktop reads 95 / 100 / 100 / 100 with LCP 1.5 s and CLS 0.</p>
<h2>Lighthouse mobile: the photo</h2>
<p>The mobile run scored 76 on performance with LCP at 7.1 s under Lighthouse&#39;s simulated slow 4G. The largest element was my own photo, served as an 800×800 WebP with <code>loading=&quot;lazy&quot;</code>. Lazy-loading the first thing on the screen is exactly wrong; I had copied the attribute from a component that is also used lower on the page.</p>
<p>Fix: the profile photo is now eager with <code>fetchpriority=&quot;high&quot;</code>, and a 480 px variant is offered through <code>srcset</code> for phone widths. Measured again, the simulated mobile LCP went from 7.1 s to 6.2 s and the score from 76 to 77. That is a small move, and the breakdown says why: the image itself loads in about 120 ms; the delay is the 173 KB HTML document and the render work before it, not the picture.</p>
<p>So I tried the document. The logo paths were embedded twice per logo, once in the markup and once in Next&#39;s inline data payload. Moving them into one external SVG sprite took the home page from 170 KB to 82 KB. Measured again, twice, with nothing else running: 78 and 74 on mobile, LCP 5.9 s and 6.1 s. Half the document, same LCP. The lever is somewhere else, and I stopped guessing for tonight; the smaller page is still worth keeping.</p>
<p><strong>Update, 16 September 2026.</strong> The lever was the local server. Every mobile number above came from <code>serve</code> on my laptop, which sends the HTML uncompressed and without cache headers. The same build on Firebase Hosting, measured with Lighthouse 12 against the live hasanyahsi.com: mobile 90 / 100 / 100 / 100 with LCP 2.4 s, desktop 99 / 100 / 100 / 100 with LCP 0.6 s. The 6 s was never the page; it was how I served it. Measuring the deployed site should have been the first step, not the last.</p>
<p>The accessibility audit on mobile also flagged the menu links: below 1100 px I had hidden the label text with <code>display: none</code>, which removes it from the accessible name as well. It is now visually hidden instead, and the mobile accessibility score is 100.</p>
<h2>What this note is not</h2>
<p>None of these numbers are a certificate. Lighthouse runs on one machine with simulated throttling; paintcheck measures one viewport at one moment. The point is smaller: I copied a colour from a reference and it failed the threshold my own tool exists to check, and I only found out because I ran the tool.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Two weeks of work on AI visibility, and Cloudflare was turning the bots away at the door</title>
      <link>https://hasanyahsi.com/notes/cloudflare-ai-bots/</link>
      <guid isPermaLink="true">https://hasanyahsi.com/notes/cloudflare-ai-bots/</guid>
      <pubDate>Mon, 14 Sep 2026 06:00:00 GMT</pubDate>
      <description>I wrote service pages and added schema so ChatGPT and Perplexity could read the site. Then I measured; every AI bot was getting 403. The cause was not the content, it was a rule enabled with one click.</description>
      <content:encoded><![CDATA[<p>At the end of August I set a goal for a small business site I maintain: when someone asks ChatGPT who builds WhatsApp assistants for clinics in İzmir, we should be one of the first answers. For two weeks I wrote three service pages, added Service and FAQ schema, filled in the Google Business Profile, registered the site in Search Console.</p>
<p>On measurement day I looked at the Cloudflare dashboard first. Under security rules:</p>
<blockquote>
<p>AI Crawl Control - Block AI bots by User Agent · Active</p>
</blockquote>
<p>The rule blocked 16 bots on every path except robots.txt, including ChatGPT-User, OAI-SearchBot, Claude-SearchBot, PerplexityBot and bingbot. In the previous 24 hours, 163 of 212 AI crawler requests had been rejected. Allowed requests for OpenAI, Anthropic, Perplexity and Bing: zero.</p>
<h2>How I measured</h2>
<p>I did not trust the dashboard; I tested from outside, requesting the same page with different User-Agent strings:</p>
<pre><code>Googlebot        200
ChatGPT-User     403
PerplexityBot    403
bingbot          403
</code></pre>
<p>Two weeks of writing had gone into pages the bots could not read.</p>
<h2>Where the rule came from</h2>
<p>Cloudflare&#39;s AI Crawl Control screen generates this rule from a single Block button. It was probably pressed months earlier with the reflex &quot;AI should not scrape my content&quot;. Reasonable that day; today the same button turns away the bot that would bring the customer.</p>
<p>There was a second layer: the managed &quot;Block AI bots&quot; setting under Security settings. Even after narrowing the custom rule, the search bots kept getting 403 until that was switched off too.</p>
<h2>What I did</h2>
<p>I did not delete the rule; I narrowed it. Training crawlers (GPTBot, ClaudeBot, CCBot, Bytespider) stay blocked. Search and live-fetch agents (ChatGPT-User, OAI-SearchBot, Claude-SearchBot, PerplexityBot, bingbot) are allowed. Then the same test again:</p>
<pre><code>ChatGPT-User     200
OAI-SearchBot    200
PerplexityBot    200
bingbot          200
GPTBot           403
ClaudeBot        403
</code></pre>
<h2>The lesson</h2>
<p>Check the door before writing content. If your site is behind Cloudflare, look once at Security → Rules and Security → Settings → Bot traffic. Anything with &quot;AI&quot; in its name may be blocking the search bots too.</p>
<p>I will measure again a week later. I do not know when the bots will come back; if the first numbers are low, that goes here too.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
