<?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>Build Log, by Mohamed Moghalis</title>
    <link>https://moghalis.com/blog</link>
    <description>What shipping real products teaches me: performance, Arabic interfaces, infrastructure. Every article comes from work that is live, and the numbers in it were measured, not estimated.</description>
    <language>en</language>
    <atom:link href="https://moghalis.com/blog/rss.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Mon, 19 Oct 2026 17:00:00 GMT</lastBuildDate>
    <item>
      <title>372 tests passed. No sale could reach the ledger.</title>
      <link>https://moghalis.com/blog/building-finyx-erp</link>
      <guid isPermaLink="true">https://moghalis.com/blog/building-finyx-erp</guid>
      <pubDate>Mon, 19 Oct 2026 17:00:00 GMT</pubDate>
      <description>How I keep a 322,000-line accounting system correct on my own: one save gate, append-only money, a hash-chained audit trail, real-database tests, and the audit that caught what they missed.</description>
      <category>ERP</category>
      <category>.NET</category>
      <category>EF Core</category>
      <category>testing</category>
      <content:encoded><![CDATA[<p>On 24 September 2026 I audited my own accounting system with one rule: reading code is not a test run. The suite passed 372 of its 373 tests. The first finding was the worst kind of bug, the kind where nothing crashes. With the default settings, no sales invoice could reach the ledger.</p>
<p>Customer balances were right. Stock was right. The cash drawer was right. Only the trial balance knew. It had no sales at all, while every purchase posted at once. On paper, the business bought and never sold.</p>
<p>FinyX-ERP is the Arabic ERP and point-of-sale system I design, build and sell alone. This article is the machinery that keeps it correct without a team, and the exact reason 372 passing tests could not see that bug. By the end you will have a save gate you can copy into any EF Core app, an audit trail that notices edits made straight on the database file, and a checklist for the bugs your tests are blind to by design.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>Every rule that protects money lives in one save gate. A screen may only offer what that gate accepts.</li>
<li>Money records are append-only, and every audit record carries the hash of the one before it, so an edit made outside the app is detected.</li>
<li>The tests run on real databases. They missed the bug because a screen kept its own copy of a rule, while the tested function had no caller.</li>
</ul>
</aside>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">322k</p><p class="stat-label">lines of code, generated migrations excluded</p></div><div class="stat"><p class="stat-value" dir="ltr">100</p><p class="stat-label">database tables</p></div><div class="stat"><p class="stat-value" dir="ltr">531</p><p class="stat-label">automated tests today</p></div><div class="stat"><p class="stat-value" dir="ltr">1</p><p class="stat-label">developer</p></div></div>
<h2 id="a-rule-in-a-screen-is-a-rule-the-next-screen-forgets">A rule in a screen is a rule the next screen forgets</h2>
<p>FinyX began as accounting software for Windows. Today the same repository holds a WPF till and back office, an ASP.NET Core server for each restaurant, a Next.js web store and three phone apps from one Expo codebase. One person cannot remember every rule in every one of those places, so the design must not depend on anyone remembering.</p>
<p>The desktop app and the restaurant server compile the same <code>Domain</code>, <code>Application</code> and <code>Infrastructure</code> source files. Every business service is registered in one method, <code>AddFinyXBusinessServices()</code>, and both hosts call it. A rule written once runs in both.</p>
<p>That settles where a rule is written. The harder question is where it is enforced. My answer: at the one place every change to the data has to pass through.</p>
<h2 id="the-gate-every-save-walks-through">The gate every save walks through</h2>
<p>FinyX treats each business event as one database transaction. An invoice, its stock movements and its journal entry commit together or not at all. Every save goes through the unit of work, and the unit of work refuses anything that would rewrite history:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">public</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> async</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Task</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">int</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> SaveChangesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">CancellationToken</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> default</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">    EnsureAppendOnlyAccountingAndAuditRecords</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // journal entries and lines, payments, cash vouchers, audit: insert only</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">    EnsurePostedDocumentsAreImmutable</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">         // a posted invoice is corrected by a new document</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> EnsureNoClosedFinancialPeriodWritesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> audits</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> BusinessAuditCapture</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Capture</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _operationId</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _user</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">CurrentUserKey</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> using</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tx</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Database</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">BeginTransactionAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> changed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">SaveChangesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> records</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> audits</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Select</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">audit</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> audit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Materialize</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToArray</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    AuditChain</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Link</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> AuditChain</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ReadTailHashAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">));</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">BusinessAuditRecords</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">AddRange</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">SaveChangesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">CommitAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> changed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>This is shortened. The real method joins an outer transaction when one exists, and it tells open screens to refresh only after the commit, so no screen ever reads a write that might still roll back.</p>
<p>Three checks, three promises.</p>
<p><strong>Append-only.</strong> Audit records, journal entries, journal lines, payments and cash vouchers are never modified or deleted through the gate. A mistake is corrected by a reversing entry, and both stay in the history.</p>
<p><strong>Posted means fixed.</strong> A posted invoice, return or expense cannot change. You correct it with a new document.</p>
<p><strong>Closed periods stay closed.</strong> The comment above that check explains why it lives here: &quot;correctness must not depend on every current or future service remembering to call one.&quot; It reads both the original and the new date of a changed row, so a historical entry cannot be moved out of a closed period, or into one. It also locks everything dated before the end of a closed period, including a date before the first period or in a gap between two. The next opening balance is the sum of everything before it, so a row slipped into a gap changes a number someone has already signed.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>Refuse</strong><span>rewrites of money, posted documents, closed periods</span></li><li><strong>Save the change</strong><span>inside one transaction</span></li><li><strong>Link the audit</strong><span>each record carries the previous record's hash</span></li><li><strong>Commit</strong><span>the change and its evidence together, or neither</span></li></ol></figure>
<h3 id="when-the-gate-was-too-strict">When the gate was too strict</h3>
<p>A gate can fail in the other direction, just as quietly. Archiving a sales invoice required it to be posted, and the posted-document check then refused the save because the invoice was posted. Archiving never succeeded once. The print counter failed the same way after every print of a posted invoice, so it stayed at zero and the invoice read &quot;never printed&quot;.</p>
<p>The fix is a closed allow-list. Four filing fields may change on a posted invoice:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">private</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> readonly</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[]</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> SaleInvoiceFilingProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsArchived</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ArchivedAtUtc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ArchivedBy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">PrintCount</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">];</span></span></code></pre></figure>
<p>If a single field outside that list changes in the same save, the whole save is refused. Anyone hoping to edit a posted amount finds no door here.</p>
<aside class="callout callout-tip"><p class="callout-label">Tip</p><p>Write every exception to an invariant as an allow-list of fields, closed by default. A deny-list grows a hole each time someone adds a column.</p>
</aside>
<h2 id="an-audit-trail-that-notices-edits-behind-its-back">An audit trail that notices edits behind its back</h2>
<p>My audit log used to promise one thing: it could not be edited from the app. That promise is weak. Anyone who opens the SQLite file with a database tool can delete a sale and its audit row together, and nothing is left to show it.</p>
<p>So every audit record now carries a SHA-256 hash of its own content plus the hash of the record before it. Change one character and its hash stops matching. Delete a record and the next one points at a hash that no longer exists.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">public</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ComputeEntryHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">BusinessAuditRecord</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> previousHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">\u001F</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">            // a separator no value contains</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">OperationId</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">N</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">OccurredAtUtc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">yyyy-MM-ddTHH:mm:ss.fff</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> CultureInfo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">InvariantCulture</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">UserKey</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Action</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EntityType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EntityKey</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ChangedProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">BeforeJson</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">AfterJson</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ApplicationVersion</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">MachineName</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        previousHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Convert</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToHexString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SHA256</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">HashData</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Encoding</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">UTF8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">GetBytes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToLowerInvariant</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>Twelve lines. Four traps hid inside them.</p>
<p><strong>Timestamps that come back different.</strong> FinyX runs on SQLite, SQL Server or PostgreSQL. SQLite hands a <code>DateTime</code> back with no <code>Kind</code>, and PostgreSQL rounds it to microseconds. Hash the full value and a record written on SQLite fails verification once the data moves to PostgreSQL. The hash takes milliseconds with no kind suffix, the one precision every provider returns intact.</p>
<p><strong>The payload is a storage format.</strong> Field order and separator are frozen. Change either and every hash ever written becomes unverifiable, so treat any change there as a data migration.</p>
<p><strong>Two writers, one tail.</strong> On a shared server, two tills can save at the same instant and both link to the same previous record. That is honest work. A verifier that demands one straight line would accuse the customer every day, and an alarm that rings every day gets switched off. So verification ignores order and checks three things: every hash matches its content, every previous hash points to a record that exists, and the chain has exactly one start.</p>
<p><strong>The tail has no witness.</strong> Delete the last record and no later record points at it. The chain alone cannot see that, so the record count and the last hash are also kept outside the database. A log that got shorter shows up as a smaller count.</p>
<p>The order inside the gate matters too. The &quot;before&quot; values are captured before the first save, while EF Core still holds the original values. The &quot;after&quot; values and any new primary keys are read after it. The tail hash is read inside the same transaction, so the tail I link to is the tail that commits. Because both saves share that transaction, a business change never commits without its evidence.</p>
<p>The test does exactly what a tamperer would do. It writes to the database file with raw SQL, from outside the app:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// edit a record outside the app: detected</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ExecuteOutsideTheSystem</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UPDATE BusinessAuditRecords SET UserKey = 'someone.else' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Verify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsIntact</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">An edited audit record went unnoticed.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// put the value back: intact again, the check holds no grudge</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ExecuteOutsideTheSystem</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UPDATE BusinessAuditRecords SET UserKey = 'audit.chain.tests' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Verify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsIntact</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Restoring the original value left the trail broken.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// delete one from the middle: detected</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ExecuteOutsideTheSystem</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">DELETE FROM BusinessAuditRecords WHERE Id = (SELECT MIN(Id) + 1 FROM BusinessAuditRecords);</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Verify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsIntact</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">A deleted audit record went unnoticed.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>On PostgreSQL, triggers guard 11 tables as well, so a posted entry or an audit record stays fixed even for someone holding an admin tool. A test compares that list with the SQL Server list, so moving a customer to a server never weakens the protection.</p>
<h2 id="tests-that-open-real-databases">Tests that open real databases</h2>
<p>FinyX does not use xUnit. Its tests are one executable harness. Each test is a method named as an English sentence that states the promise it keeps, and the ones that touch data run against a real SQLite database in their own temporary folder. Each test prints a <code>METRIC</code> line with the numbers it proved, then <code>PASS</code>, <code>FAIL</code> or <code>SKIP</code>:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.\</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">AccountsSystem.Tests.exe</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">METRIC TRIAL_BALANCE_ROWS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">BALANCED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">TRUE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">SHORTAGE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100.00</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">CASH</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">900.00</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS TrialBalance_BalancesAndNamesWhoBearsTheCashShortage</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">METRIC AUDIT_CHAIN_EDIT_DETECTED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">AUDIT_CHAIN_DELETE_DETECTED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">CLEAN_TRAIL_NOT_ACCUSED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS AuditTrail_DetectsAnEditedOrDeletedRecordMadeOutsideTheSystem</span></span></code></pre></figure>
<p>A <code>METRIC</code> line turns a green test into evidence. You can read what it proved without opening the code. <code>SKIP</code> exists for one reason: a test the environment cannot run is announced and counted, never reported as a failure, because a red that means nothing trains you to ignore red. The release script runs the whole desktop suite before it publishes anything, and stops if any test fails.</p>
<p>With all of that in place, the suite said 372 of 373. Here is what it could not see.</p>
<h2 id="what-372-green-tests-could-not-see">What 372 green tests could not see</h2>
<p>The sales screen shows how many invoices are waiting to be posted, with a button that posts them. In the view model, that list required every invoice to be approved. Approval is a policy, <code class="code-long">SalesRequireApprovalBeforePosting</code>, and it ships switched off. So with the default settings, no invoice ever reached that list, and the button had nothing to post.</p>
<p>Why did nobody notice? Because everything a cashier or an owner checks every day was right. Customer balances, stock and the cash drawer all include unposted documents on purpose, so they stay correct before the accountant posts. Only the reports that read the ledger itself, the trial balance and the income statement, were missing every sale.</p>
<p>Why did 372 tests not notice? This is the part worth stealing. The service already had the right function, <code>GetUnpostedSaleInvoicesAsync</code>. It was tested. And nothing called it. The screen built its own list from the rows it happened to be showing, with its own copy of the rule. The tests proved a function the product never ran.</p>
<p>The same mistake had a second symptom. Because the list came from the visible rows, it changed with the screen&#39;s date filter. With the range set to &quot;today&quot;, yesterday&#39;s invoice could not be posted at all.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>The sales screen (what ran)</span><span>The service (what was tested)</span></div><div class="dg-compare-row"><span>Approval always required</span><span>Approval only when the policy asks for it</span></div><div class="dg-compare-row"><span>Built from the rows on display</span><span>Read from the database</span></div><div class="dg-compare-row"><span>Changed with the date filter</span><span>Every unposted invoice</span></div><div class="dg-compare-row"><span>Empty with default settings</span><span>Correct, with no caller</span></div></div></figure>
<p>The rule moved into the service, which now reads the same policy as the posting itself, and the screen takes its list from the service:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// AccountingJournalService.GetUnpostedSaleInvoicesAsync</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requireApproval</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _operationalPolicyService</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">GetAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SalesRequireApprovalBeforePosting</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">requireApproval</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    query</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> query</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Where</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">invoice</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> invoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ApprovedBy</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !=</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> invoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ApprovedBy</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ""</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>What the screen offers for posting is now exactly what the service accepts.</p>
<aside class="callout callout-warning"><p class="callout-label">Watch out</p><p>A tested function with no caller is a passing test for code you do not ship. Look for service methods that nothing in the interface calls. Each one may point to a screen with its own copy of the rule.</p>
</aside>
<h3 id="cancel-must-never-touch-the-ledger">Cancel must never touch the ledger</h3>
<p>The same audit found that pressing Edit on a posted invoice removed its journal entry first and opened the editor second. Press Cancel and the invoice left the books: revenue, tax, cost and the receivable gone, the goods still out of stock, the invoice still in the list. No message.</p>
<p>Now the editor opens first. If it closes without confirmation, the ledger is never touched. The invoice is unposted only after confirmation, right before the change is applied, and any failure after that restores the posting. If even the restore fails, a window names the invoice and says its entry is missing from the ledger.</p>
<p>How do you test a decision that lives behind a modal dialog, in a suite with no screen? You test the order in the source:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> body</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> MethodBody</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">File</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ReadAllText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">salesViewModelPath</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">private async Task EditSelectedInvoiceAsync()</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> dialogGuard</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">IndexOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">editor.ShowDialog() != true</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> StringComparison</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Ordinal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> unpost</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">IndexOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UnpostSaleInvoiceAsync</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> StringComparison</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Ordinal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dialogGuard</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> >=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">The editor is no longer opened before the invoice is amended.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unpost</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> dialogGuard</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">The ledger is stripped before the editor is confirmed.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Contains</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">RestorePostingAfterAbandonedEditAsync</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">    "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Nothing restores the entry when the amend fails after the invoice was unposted.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>It is crude, and it measures the exact thing that broke.</p>
<h3 id="the-escape-hatch-that-walked-around-the-gate">The escape hatch that walked around the gate</h3>
<p>Unposting an invoice has to remove its journal entry, which the gate forbids. So unposting and administrative deletion had a path of their own: raw SQL, with the database triggers suspended. That path never passed through the unit of work, so it never met the closed-period check. An entry from a month already closed could be erased.</p>
<p>The fix runs the period check before the connection opens and before any trigger is suspended. A request against a closed period is refused before a single row is touched.</p>
<p>Every path around a gate has to carry the gate&#39;s checks with it. The fastest way to find yours is to search the codebase for raw SQL.</p>
<h3 id="the-red-test-everyone-learns-to-skip">The red test everyone learns to skip</h3>
<p>The one failing test of the 373 failed on every normal run. It needed a database prepared beforehand by a separate script, so for anyone else it was always red. A permanent failure in an otherwise green list teaches its reader to skip it, and the first real failure gets skipped with it. The test now builds its own database in a fresh temporary folder and removes it afterwards. After the fixes the suite ran 376 of 376.</p>
<p>Less than a month later, the desktop suite has 475 tests.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">Desktop (WPF)</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">475</span></div><div class="dg-bar-row"><span class="dg-bar-label">Server (ASP.NET Core)</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.063"></span></span><span class="dg-bar-value" dir="ltr">30</span></div><div class="dg-bar-row"><span class="dg-bar-label">Web</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.034"></span></span><span class="dg-bar-value" dir="ltr">16</span></div><div class="dg-bar-row"><span class="dg-bar-label">Integrations library</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.021"></span></span><span class="dg-bar-value" dir="ltr">10</span></div></div></figure>
<h2 id="the-checklist-i-keep">The checklist I keep</h2>
<ul>
<li>Put every invariant that protects money in the save path. Screens may warn early. The save path decides.</li>
<li>A screen offers only what the service accepts. Read the list from the service, never from the rows on display.</li>
<li>Make money records append-only, and correct them with reversing documents.</li>
<li>In a period check, test the original date and the new one, and lock the gaps before a closed period&#39;s end.</li>
<li>Write exceptions to an invariant as closed allow-lists of fields.</li>
<li>Chain your audit records: fixed field order, a separator no value contains, a timestamp precision every database returns, verification that tolerates concurrent writers, and a witness for the tail outside the database.</li>
<li>In tests, write to the database file from outside the app, and prove you notice.</li>
<li>Search for raw SQL. Every path around the gate must repeat the gate&#39;s checks.</li>
<li>Search for tested service methods that nothing calls.</li>
<li>Allow no permanently red test. Skip loudly, and count the skips.</li>
<li>Audit with one rule: reading code is not a test run, and a successful build proves nothing about the accounting.</li>
</ul>
<p>If I started FinyX again tomorrow, I would write the save gate before the first screen. The audit&#39;s first findings were two rules that lived in a screen and one raw SQL path around the gate. Put the rule where the data changes, then write the test that proves the screen uses it.</p>
<p>It is the same discipline I bring to the <a href="https://moghalis.com/services/business-systems">business systems</a> I build for clients.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Arabic on a thermal printer, and the five bytes that ate a roll</title>
      <link>https://moghalis.com/blog/arabic-thermal-receipts</link>
      <guid isPermaLink="true">https://moghalis.com/blog/arabic-thermal-receipts</guid>
      <pubDate>Mon, 19 Oct 2026 16:00:00 GMT</pubDate>
      <description>Why ESC/POS printers mangle Arabic, how to print it as a 1-bit image at the head's exact width, and the geometry bug that turned a 12 cm receipt into a roll on the floor.</description>
      <category>ESC/POS</category>
      <category>Arabic</category>
      <category>RTL</category>
      <category>C#</category>
      <category>printing</category>
      <content:encoded><![CDATA[<p>The receipt printed. Then the paper kept coming.</p>
<p>It was an Xprinter XP-370B on a till in real use, loaded with 76 mm roll, and it fed paper until the roll ran out. Measured on the bench without printing, the job asked for 124.7 mm. The job was bounded. The printer was not.</p>
<p>The cause was five bytes. Every row of the receipt image was 72 bytes wide, on a head that prints 67.</p>
<p>FinyX-ERP prints every receipt in Arabic, with a QR code at the bottom, on 203 DPI printers that cost little and forgive nothing. It never sends the printer a single Arabic character. By the end of this article you will be able to print clean Arabic on any ESC/POS printer, with a hard ceiling on paper, and you will know exactly how five bytes turn pixels into commands.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>Shape Arabic on the computer and send the printer a 1-bit picture. Its built-in fonts and code pages cannot join letters or lay a line out right to left.</li>
<li>Compose the page at the exact width the head prints, in dots. Scaling a 1-bit image afterwards is what makes Arabic look ragged.</li>
<li>Make every raster row exactly as wide as the print area you declare with <code>GS W</code>. A wider row makes the firmware lose its place and execute your pixels.</li>
</ul>
</aside>
<h2 id="why-the-printer-cannot-write-arabic">Why the printer cannot write Arabic</h2>
<p>A receipt printer&#39;s text mode was built for Latin script: fonts in ROM, and code pages that map one byte to one glyph. Arabic asks for more. Each letter takes up to four shapes depending on its neighbours. Lam and alef fuse into one glyph. A line runs right to left while the numbers inside it run left to right. I wrote about those joining rules in <a href="https://moghalis.com/blog/arabic-text-effects">why per-letter text effects break Arabic</a>. Text mode does none of it.</p>
<p>In practice, firmware code pages disconnect the letters, reverse the run, or substitute glyphs that have nothing to do with Arabic, and two units of the same model can behave differently. I cannot support behaviour I cannot predict.</p>
<p>So in FinyX-ERP, Arabic never reaches a printer as text. The computer shapes it with a real text engine, the whole receipt becomes a black-and-white picture, and the printer only ever receives raster images. Barcode labels follow the same rule: on the TSPL label printers, the Arabic product name goes as a bitmap too.</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">203</p><p class="stat-label">dots per inch, one bit per dot</p></div><div class="stat"><p class="stat-value" dir="ltr">536</p><p class="stat-label">dots the head really prints on 76 mm paper</p></div><div class="stat"><p class="stat-value" dir="ltr">27 mm</p><p class="stat-label">of feed after the last line, sent as 216 dots</p></div><div class="stat"><p class="stat-value" dir="ltr">800 mm</p><p class="stat-label">the most paper one receipt may use</p></div></div>
<p>Pictures solve Arabic completely. They also carry the risk at the heart of this story, because to the printer a picture is just more bytes in the command stream.</p>
<h2 id="five-steps-from-a-sale-to-burned-dots">Five steps from a sale to burned dots</h2>
<p>QuestPDF composes the receipt right to left on a continuous page with no fixed height, exactly as wide as the print area. The page is rendered at 203 DPI, so one pixel is one printer dot, and from there it is bits all the way down.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow is-long"><li><strong>Lay out the receipt</strong><span>a QuestPDF page, right to left, 536 px wide</span></li><li><strong>Render</strong><span>PNG at 203 DPI: one pixel per printer dot</span></li><li><strong>Threshold</strong><span>ink below 180, transparency composited on white</span></li><li><strong>Encode</strong><span>ESC/POS raster bands of 128 rows</span></li><li><strong>Send it RAW</strong><span>through the Windows spooler, never retried</span></li></ol></figure>
<p>The encoder is short. Three lines in it do the real work:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// Rec. 601 luma, in integer form.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> luminance</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">77</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> red</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 150</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> green</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 29</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> blue</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> >></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// Composite transparent pixels onto white first. Without it, an unpainted</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// background reads as black and the receipt prints as a black rectangle.</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">luminance</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">luminance</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> alpha</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 255</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">255</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> alpha</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 255</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">luminance</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> InkThreshold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // 180</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    raster</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">y</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> rowBytes</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> x</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |=</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">byte</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0x80</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> >></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">x</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> %</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">));</span></span></code></pre></figure>
<p>Each dot is one bit, and the leftmost dot of a byte is its highest bit, so dot <code>x</code> lands in byte <code>x / 8</code> under the mask <code>0x80 &gt;&gt; (x % 8)</code>. A row of 536 dots is 67 bytes. Hold on to that number.</p>
<p>The bands go out with <code>GS v 0</code>, 128 rows at a time. That command advances the paper by exactly the image height, so bands meet with no seam; a line feed between them would open a white stripe. Below the last inked row, everything except a margin of about 2 mm is dropped, so no hand&#39;s length of blank paper follows the receipt.</p>
<p>The spooler call is plain winspool with data type <code>RAW</code>, in 64 KB chunks. The Windows driver installed for this printer is a label driver, and RAW keeps it from turning ESC/POS into its own TSPL. If anything fails halfway, the code calls <code>AbortPrinter</code> and never retries: an ambiguous partial job sent twice can duplicate a receipt or empty the roll.</p>
<h2 id="seven-commands-every-byte">Seven commands, every byte</h2>
<p>A receipt needs seven commands at most. Here they are with the exact bytes for 76 mm paper on the XP-370B. Two-byte numbers are little-endian.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Command</th>
<th>Bytes</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>ESC @</code></td>
<td><code>1B 40</code></td>
<td>Clears whatever the previous job left</td>
</tr>
<tr>
<td><code>GS L</code></td>
<td><code>1D 4C 18 00</code></td>
<td>Left offset of 24 dots</td>
</tr>
<tr>
<td><code>GS W</code></td>
<td><code>1D 57 18 02</code></td>
<td>Print area of 536 dots (0x0218)</td>
</tr>
<tr>
<td><code>GS v 0</code></td>
<td><code>1D 76 30 00 43 00 80 00</code></td>
<td>One band, 67 bytes wide and 128 rows tall, followed by its bits</td>
</tr>
<tr>
<td><code>ESC J</code></td>
<td><code>1B 4A D8</code></td>
<td>Feeds 216 dots, which is 27 mm</td>
</tr>
<tr>
<td><code>GS V</code></td>
<td><code>1D 56 42 18</code></td>
<td>Feeds to the cutter and makes one partial cut, on printers that have one</td>
</tr>
<tr>
<td><code>ESC p</code></td>
<td><code>1B 70 00 19 19</code></td>
<td>Pulses the cash drawer (m = 0, connector pin 2), 50 ms on and 50 ms off</td>
</tr>
</tbody></table></div>
<p>Look at the <code>GS W</code> row. 536 is the most important number in this article, and I did not choose it.</p>
<h2 id="the-paper-is-wider-than-the-print">The paper is wider than the print</h2>
<p>At 203 DPI a millimetre is about eight dots, and only at 203 DPI. So millimetres become dots in exactly one function, against the printer profile&#39;s own resolution, rounding half away from zero so that a barcode module never shrinks.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Paper</th>
<th>Print area</th>
<th>Head offset</th>
</tr>
</thead>
<tbody><tr>
<td>58 mm</td>
<td>384 dots (48 mm)</td>
<td>0</td>
</tr>
<tr>
<td>80 mm</td>
<td>576 dots (72 mm)</td>
<td>0</td>
</tr>
<tr>
<td>76 mm, on the XP-370B I tested with</td>
<td>536 dots (67 mm)</td>
<td>24 dots</td>
</tr>
</tbody></table></div>
<p>The first two are the generic profiles. The XP-370B numbers were measured: the paper is 607 dots wide, the head marks 536 of them, and the first 24 are mechanically out of reach. Using the full width clipped the right-hand column. On an Arabic receipt the right edge is where every line starts, which makes it the worst edge to lose.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">76 mm paper</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">607dots</span></div><div class="dg-bar-row"><span class="dg-bar-label">What the head can print on it</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.883"></span></span><span class="dg-bar-value" dir="ltr">536dots</span></div><div class="dg-bar-row"><span class="dg-bar-label">80 mm paper, printable</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.949"></span></span><span class="dg-bar-value" dir="ltr">576dots</span></div><div class="dg-bar-row"><span class="dg-bar-label">58 mm paper, printable</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.633"></span></span><span class="dg-bar-value" dir="ltr">384dots</span></div></div></figure>
<p>The settings offer five print areas (384, 512, 536, 576 and 608 dots), and the default is the conservative 536. A narrower area always prints. A wider one is what breaks things, and soon you will see how badly.</p>
<p>The biggest quality gain came from one decision. My first receipts were composed 80 mm wide and scaled down to 536 dots by the encoder, a factor of 0.84. Scaling a 1-bit image is what makes Arabic look ragged: a shrunken stroke lands between dots, and the threshold has to guess. Now the page is composed at the print area itself:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// 536 dots at 203 DPI = 67.07 mm. One PDF pixel becomes one printed dot.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">internal</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> float</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> PageWidthMm</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">DocumentPrintSettings</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> settings</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">float</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settings</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ReceiptPrintAreaDots</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 25.4d</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> RasterDpi</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<aside class="callout callout-tip"><p class="callout-label">Tip</p><p>The print area belongs to the printer and the paper loaded in it. Measure it on the real device, store it per printer, and send it with every job.</p>
</aside>
<p>That 536 came from a roll on the floor, and we will get there. First, the Arabic itself needs rules, because one bit per dot is a harsh medium.</p>
<h2 id="making-arabic-survive-one-bit">Making Arabic survive one bit</h2>
<p>A 203 DPI head has no grey. Each dot burns or it does not, and a stroke thinner than one dot simply does not print. Four rules follow from that.</p>
<p><strong>Choose the font by test.</strong> I rendered the same invoice in Ping AR + LT and in Cairo at real size, through the same threshold, and compared. Ping is narrower and more regular, so a line fits a longer item name, and its stems stay a full dot wide at 7.5 pt. Cairo starts to crumble at those sizes. Ping became the thermal default.</p>
<p><strong>Medium weight for body text.</strong> At 8 pt, the joins of a regular weight drawn for backlit screens fall below one dot and break, which reads as a ragged letter.</p>
<p><strong>A floor of 6.5 pt.</strong> That is about 18 dots tall. Below it, the dots that tell ب from ت from ث start merging into the letter.</p>
<p><strong>No colour and no grey.</strong> Everything collapses to black or white at the threshold, so a light shaded band prints either nothing or a black block over its own text. The document title is white text reversed out of a solid black band.</p>
<p>Body text is 8 pt, labels 7.5 pt, the grand total 13 pt. Sizes that small work only because nothing is scaled on the way to the head.</p>
<h3 id="digits-and-direction">Digits and direction</h3>
<p>With an Arabic culture, the text stack swaps in Arabic-Indic digits, so <code>76,400.00</code> prints as <code>٧٦٬٤٠٠٫٠٠</code>. Readable, but a customer compares the price with a shelf label and a supplier reads barcode numbers back, and both want Western digits. So Western digits are the default and Arabic-Indic is a setting. In the WPF renderer that draws labels, the switch is one object:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_numbers</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> NumberSubstitution</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    NumberCultureSource</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Override</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    CultureInfo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">GetCultureInfo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">arabicIndicDigits</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ar-SA</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">en-US</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    arabicIndicDigits</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> NumberSubstitutionMethod</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">NativeNational</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> NumberSubstitutionMethod</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">European</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>A date with a time, such as <code>2026/09/08 14:35</code>, holds two Latin runs. Inside an Arabic paragraph they are laid out right to left, so the time reaches the paper before the date. Values like that go inside a left-to-right isolate:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// U+2066 LEFT-TO-RIGHT ISOLATE … U+2069 POP DIRECTIONAL ISOLATE</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">internal</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Ltr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">IsNullOrEmpty</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> value</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> $"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">⁦</span><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">⁩</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span></code></pre></figure>
<p>An isolate fixes the character order and leaves placement to the right-to-left flow. A left-to-right container would also push the value to the left edge of its cell.</p>
<p>Never put Arabic text inside the isolate: in there, the Yemeni rial symbol «ر.ي» reverses its letters. So currency prints as its ISO code (<code>YER</code>, <code>SAR</code>), once, on the grand total. Two bugs from this family reached paper. A payment line built without the isolate printed <code>YER 75,000.00</code> reversed, and a negative balance printed as <code>50,000.00-</code> because the minus sign sat outside the isolate. Both now go through one formatter.</p>
<p>Shaped, sized and ordered, the receipts finally looked right. Then a till in real use printed one and kept going.</p>
<h2 id="the-roll-on-the-floor">The roll on the floor</h2>
<p>The first theory looked solid. The driver advertised 38.1 mm media while the receipt was 76 mm wide, so the printer seemed stuck in label mode, hunting for a gap that continuous paper does not have.</p>
<p>I disproved it with a PowerShell script. It sent raw ESC/POS to the same printer with the same settings, printed one clean copy, and stopped. A RAW job bypasses the driver completely, so the driver&#39;s media size could not be the cause. The difference had to be in the bytes.</p>
<p>So I measured the job without sending it. A diagnostic runs the real composer and encoder and writes out the command stream instead of paper:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>QuestPDF: 1 page, 639 × 1037 px = 129.8 mm</span></span>
<span class="line"><span>ESC @ · ESC a 1 · GS L 0 · GS W 576 · 8 × GS v 0 (72 bytes × 128 rows) · ESC d 3</span></span>
<span class="line"><span>paper requested: 124.7 mm</span></span></code></pre></figure>
<p>Twelve centimetres, and not one stray byte. What differed from the working script was the geometry:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th></th>
<th>Script that worked</th>
<th>FinyX-ERP before the fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>GS L</code>, left offset</td>
<td>24 dots</td>
<td>0</td>
</tr>
<tr>
<td><code>GS W</code>, print area</td>
<td>536 dots</td>
<td>576</td>
</tr>
<tr>
<td>Raster row</td>
<td>67 bytes = 536 dots</td>
<td>72 bytes = 576 dots</td>
</tr>
<tr>
<td>Alignment</td>
<td>none</td>
<td><code>ESC a 1</code></td>
</tr>
<tr>
<td>Ending</td>
<td><code>ESC J 240</code>, feed in dots</td>
<td><code>ESC d 3</code>, feed in lines</td>
</tr>
</tbody></table></div>
<p>There are the five bytes. At offset zero, 576 dots is wider than this head prints on 76 mm paper. Given raster rows wider than its print area, the firmware resynchronises mid-image, and from then on it reads image bytes as commands. Raster data is full of bytes that mean &quot;feed&quot;. That is how a 12 cm receipt becomes a roll on the floor.</p>
<p><code>ESC d</code> made it worse. It feeds in lines, and some label-capable firmware reads it as &quot;advance to the next form&quot;, which continuous paper never has.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>Before</span><span>After</span></div><div class="dg-compare-row"><span>GS L 0, GS W 576</span><span>GS L 24, GS W 536</span></div><div class="dg-compare-row"><span>72 bytes per raster row</span><span>67 bytes per raster row</span></div><div class="dg-compare-row"><span>Rows wider than the printable band</span><span>Rows that fit the band exactly</span></div><div class="dg-compare-row"><span>Firmware resyncs and reads image bytes as feeds</span><span>The receipt ends at 134.9 mm</span></div></div></figure>
<p>The fix had three parts:</p>
<ul>
<li>Every raster row is exactly as wide as <code>GS W</code> declares, <code>rowBytes = printAreaDots / 8</code>, and anything beyond is cropped. Printer and encoder can never disagree on the byte count, and a test asserts <code>rowBytes × 8 == width</code> exactly.</li>
<li><code>ESC J</code> in dots replaces <code>ESC d</code>, split into steps of at most 255 because it takes a single byte.</li>
<li><code>ESC a 1</code> is gone. The raster fills the whole area, so alignment only added another way for the two sides to disagree.</li>
</ul>
<p>After the fix, the stream matches the working script command for command:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>ESC @ · GS L 24 · GS W 536 · 7 × GS v 0 (67 bytes × 128 rows) · ESC J 216</span></span>
<span class="line"><span>paper requested: 134.9 mm</span></span></code></pre></figure>
<aside class="callout callout-warning"><p class="callout-label">Watch out</p><p>Never send a raster row wider than the print area you declared. The printer does not reject it. It loses its place in the stream and starts executing your pixels.</p>
</aside>
<p>Then I made sure the next layout fault would end as an error message. A receipt may use at most 800 mm of paper, feed included, checked before the first byte leaves; the old ceiling was 40,000 rows, about five metres. A button in the printing settings empties the printer&#39;s queue, and a script does the same without the app:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Pause the queue so nothing new starts, then cancel what is already in it.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Add-Type</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> -</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">AssemblyName System.Printing</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">,</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ReachFramework</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">$</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">server</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> New-Object</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> System.Printing.LocalPrintServer</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">$</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue</span><span style="--shiki-light:#999999;--shiki-dark:#666666">  =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">server.GetPrintQueues</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> Where-Object</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> FullName </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">-eq</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Xprinter XP-370B</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">$</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue.Pause</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue.Commit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">foreach</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ($</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">job</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> in</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue.GetPrintJobInfoCollection</span><span style="--shiki-light:#999999;--shiki-dark:#666666">())</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">job.Cancel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">job.Dispose</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>Know its limit. Paper already moving comes from the printer&#39;s own buffer and stops when that drains. If it keeps going after that, the printer is feeding on its own, and only the power switch or the media setting will stop it.</p>
<p>The same error log held a second way to waste a roll. <code>Process.Start</code> with the <code>print</code> verb sends a PDF to the Windows default printer. On a till where that is the thermal printer, an A4 page goes through the thermal driver onto a roll with no page length. Now the PDF opens, and a person picks the printer.</p>
<h2 id="the-qr-the-feed-the-cut-and-the-drawer">The QR, the feed, the cut and the drawer</h2>
<p><strong>The QR.</strong> The QR is drawn as SVG: one square per module, <code>shape-rendering=&quot;crispEdges&quot;</code>, no smoothing. A raster QR that gets resized has modules that bleed into each other, and scanners give up. A vector is drawn straight onto the printer&#39;s own grid.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> matrix</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> QRCodeWriter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">encode</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">content</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> BarcodeFormat</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">QR_CODE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    new</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Dictionary</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">EncodeHintType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> object</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EncodeHintType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ERROR_CORRECTION</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ErrorCorrectionLevel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">M</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EncodeHintType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">MARGIN</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // the quiet zone is added by hand: 4 modules</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    });</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// then one "M x,y h1v1h-1z" square per dark module</span></span></code></pre></figure>
<p>Error correction is level M. The receipt&#39;s QR carries about 550 characters, level H would make it too big to print at a readable size, and M survives an ordinary scratch or ink spot. It prints 38 × 38 mm, centred between the totals and the footer.</p>
<p><strong>The feed.</strong> After the last line the paper advances 27 mm, so the footer clears the tear bar. <code>ESC J</code> counts motion units, one dot on these printers, so 27 mm is 216 dots.</p>
<p><strong>The cut.</strong> Only when the printer has a cutter, because a cut sent to one without a cutter jams it or prints as characters. The XP-370B&#39;s driver offers None, Tear Off and Peel Off, and no Cut, so the cashier tears. Elsewhere, <code>GS V</code> makes one partial cut after the whole receipt. The live path still recognises the XP-370B by name; the profile-driven encoder I am moving to reads the cutter from the printer&#39;s profile.</p>
<p><strong>The drawer.</strong> <code>ESC p</code> pulses the cash drawer. It lives in the profile-driven encoder and is off by default, because a pulse on a printer wired to something else is at best a click.</p>
<h2 id="before-you-print-a-single-receipt">Before you print a single receipt</h2>
<ul>
<li>Treat Arabic as pixels from the first day.</li>
<li>Measure the print area on the real printer, keep it per printer, and declare it in every job with <code>GS L</code> and <code>GS W</code>.</li>
<li>Make every raster row exactly <code>printAreaDots / 8</code> bytes, and crop anything wider.</li>
<li>Compose at the final width. Never scale a 1-bit image.</li>
<li>Composite transparency onto white before the threshold.</li>
<li>Feed in dots with <code>ESC J</code>, and cap the paper before sending anything.</li>
<li>Preview the bits the head will burn. My preview tool runs pages through the real encoder and saves PNGs; it caught wrapping, reversed text and extra length before they cost paper.</li>
<li>Never retry a print automatically.</li>
</ul>
<p>Once Arabic became pixels, every problem left was geometry: how wide to print, how far to feed, where to stop. Five bytes of it cost a whole roll.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Why idempotency keys didn't stop 105 resends of one receipt</title>
      <link>https://moghalis.com/blog/electricity-billing</link>
      <guid isPermaLink="true">https://moghalis.com/blog/electricity-billing</guid>
      <pubDate>Mon, 19 Oct 2026 15:00:00 GMT</pubDate>
      <description>Offline-first billing for electricity stations: one id from the phone to the receipt, why bill totals must never be added, an outbox that knows what to retry, and the 500 that looped.</description>
      <category>billing</category>
      <category>PostgreSQL</category>
      <category>Flutter</category>
      <category>NestJS</category>
      <category>offline-first</category>
      <content:encoded><![CDATA[<p>22:02, Aden time, 18 October. At a station in Yemen, two phones start sending the same receipt to my API. The API answers 500. The phones send it again, and again. By 22:06 the receipt has arrived 105 times, and the platform&#39;s health screen reads 9% server errors.</p>
<p>No balance moved by a single rial. The database refused every one of those inserts.</p>
<p>And every one of those requests passed through an idempotency layer, built for exactly this: phones that resend.</p>
<p>This is how FinyxWatt bills electricity customers from phones that lose the network. It covers the id that follows a payment from the phone to the receipt, the reason bill totals must never be added, the outbox that decides what to retry, and the rule I was missing that night. By the end you will be able to build an offline queue for money where a retry can never charge twice and a refusal can never loop.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>A command gets its id on the phone, the moment it is saved, and keeps it until the server confirms. The server claims that id before it touches any money.</li>
<li>A balance is the sum of an append-only ledger. A bill&#39;s total contains older debt, so adding totals counts the same debt twice.</li>
<li>An idempotency key replays successes only. For failures, your status codes are the retry policy, and a permanent failure that answers 500 becomes a loop.</li>
</ul>
</aside>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">105</p><p class="stat-label">sends of one receipt in 3.5 minutes</p></div><div class="stat"><p class="stat-value" dir="ltr">9%</p><p class="stat-label">server errors on the health screen</p></div><div class="stat"><p class="stat-value" dir="ltr">0</p><p class="stat-label">duplicate receipts recorded</p></div><div class="stat"><p class="stat-value" dir="ltr">1</p><p class="stat-label">function in the fix</p></div></div>
<h2 id="where-the-network-drops-the-phone-keeps-the-work">Where the network drops, the phone keeps the work</h2>
<p>A private electricity station runs on a chain of small steps. A collector reads the meter, a bill is issued, the customer gets an SMS, someone collects the money, and the cash moves up to the manager. FinyxWatt is the platform I built for that chain: four Flutter apps on one NestJS API over PostgreSQL. The station app is the one in the collectors&#39; hands, and stations lose the network often.</p>
<p>So the app never waits for the network. It shows saved copies first, and every write goes into an outbox on the phone. When the connection returns, one coordinator uploads in dependency order: new customers first, then the bills and payments that may refer to them, then chat. A payment for a bill that so far exists only on the phone waits its turn. After a failed run the coordinator waits 30 seconds, doubling up to five minutes, so a struggling server is not hammered.</p>
<p>The first version ran on Firebase. Moving it to my own backend while stations kept working left me with one rule: the phone proposes, the server decides.</p>
<p>Retries are the whole design, which makes two questions unavoidable. If a retry is normal, what stops a retry from charging twice? And what stops a retry from never ending? The first has a clean answer. The second is the night of 18 October.</p>
<h2 id="give-the-command-its-id-at-birth">Give the command its id at birth</h2>
<p>Every command gets a random UUID the moment it is first saved on the phone, and keeps it across retries, app restarts and lost responses. The id is also the identity of what it creates: a queued bill is created on the server under that id, and so is a payment.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/// A new random (version 4) id, created when a command is first queued.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">String</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> newOperationId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">() {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  final</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> random </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> Random</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">secure</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  final</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> bytes </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> List</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">&#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">int</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">></span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">generate</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">16</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (_) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=></span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> random</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">nextInt</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">256</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x26;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x0f</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">|</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x40</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">8</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">8</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x26;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x3f</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">|</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x80</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> _format</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(bytes)</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/// The id stored with a queued command, or a derived one for older items.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">String</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> operationIdFor</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Map</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">&#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">String</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">dynamic</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">> payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> String</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> queueItemId) {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  final</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> stored </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> payload[</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">'operationId'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (stored </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">is</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> String</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> isCanonicalUuid</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(stored)) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">return</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> stored</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toLowerCase</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> derivedOperationId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(queueItemId)</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">}</span></span></code></pre></figure>
<p>The second function handles the trap that shows up after an upgrade. A new version of the app can land on a phone that still holds commands queued by an older version, which stored no id. Generate a fresh id on every retry and you have built the double charge you set out to prevent. So an old item gets an id derived from its own permanent queue id. It is the same value on every retry, and it is marked as UUID version 8 so anyone reading it knows it was derived.</p>
<p>A second trap hides inside that derivation. It uses 32-bit FNV-1a, and the multiplication by the FNV prime is spelled out as shifts:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">hash </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 4</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 7</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 24</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">)) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x26;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0xffffffff</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span></code></pre></figure>
<p>The same Dart code also builds for the browser, where integers are doubles. A plain multiplication there can pass 2^53 and lose its low bits, and the browser would derive a different id from the same queue item than the phone does.</p>
<h2 id="claim-the-id-before-doing-the-work">Claim the id before doing the work</h2>
<p>On the server the id arrives as the <code>Idempotency-Key</code> header, and the API claims it before it touches any money. The claim and the command share one transaction:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">claimed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">insertInto</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">idempotency_receipts</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">values</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">actor_identity_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">idempotency_key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">operation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  request_hash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">sha256</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">stableStringify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)),</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  status</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">processing</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">onConflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">columns</span><span style="--shiki-light:#999999;--shiki-dark:#666666">([</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tenant_id</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">actor_identity_id</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">idempotency_key</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">doNothing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">())</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">returning</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">idempotency_key</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">executeTakeFirst</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">claimed</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> readReceiptForUpdate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // SELECT … FOR UPDATE</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> throw</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> conflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">IDEMPOTENCY_RECEIPT_UNAVAILABLE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // fail closed</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">operation</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !==</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> operation</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">request_hash</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !==</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> requestHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> throw</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> conflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">IDEMPOTENCY_KEY_REUSED</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">status</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !==</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">completed</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> throw</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> conflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">OPERATION_IN_PROGRESS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> { </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">replayed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">status</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">response_status</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">response_body</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">result</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">       // first time: do the work</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> markCompleted</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> result</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // and store its response in the same transaction</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> { </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">replayed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">false</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, ...</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">result</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>Each line closes a door.</p>
<ul>
<li>Two copies of the same request arriving together cannot both run. PostgreSQL makes the second <code>ON CONFLICT</code> wait for the first transaction, and the loser reads the finished receipt.</li>
<li>A receipt that exists but cannot be read fails closed. Running the command a second time is the one thing this code must never do.</li>
<li>A known key sent with a different body is a client bug, and it gets a 409 instead of a replay. The body is hashed after a stringify that sorts object keys, so the same command always hashes the same.</li>
<li>A key still in <code>processing</code> answers <code>OPERATION_IN_PROGRESS</code>, and the phone files that under &quot;try again later&quot;.</li>
</ul>
<p>One trap here took a migration to fix. My first receipts table was keyed by tenant, actor, operation and key. It looked tidy, and it allowed one key to be used once per endpoint. If a retry were ever routed to the wrong endpoint, the same key could create a second effect. Migration 0015 made each key single-use per actor across every command type.</p>
<figure class="post-figure dg dg-is-timeline"><ol class="dg-timeline"><li><strong>Offline</strong><span>the phone saves payment 7f3… in its outbox</span></li><li><strong>Back online</strong><span>it posts the payment with Idempotency-Key 7f3…</span></li><li><strong>The API commits</strong><span>the receipt is recorded and its response stored</span></li><li><strong>The response is lost</strong><span>the phone never hears back</span></li><li><strong>Retry with the same key</strong><span>the API returns the stored response, marked replayed</span></li></ol></figure>
<p>A lost response is now harmless. Hold on to one detail from that code, though: the response is stored in the same transaction as the work. It matters later.</p>
<h2 id="the-server-does-the-arithmetic">The server does the arithmetic</h2>
<p>An idempotent request is only safe if it carries no money the phone made up. The phone sends a meter reading. The server does the rest.</p>
<p>A bill&#39;s charge is (current reading − previous reading) × price per kWh, rounded to two decimals in PostgreSQL <code>numeric</code>. The previous reading comes from the meter row, which the server locks for the length of the transaction. The price is the customer&#39;s own while it is within its dates, otherwise the station&#39;s. A price sent by a phone counts only for a station that has never stored one, so a phone that has been offline for weeks cannot bill at an old price.</p>
<p>Side effects need the same discipline. The customer&#39;s SMS is created in the same transaction as the bill, under the key <code>invoice:&lt;id&gt;:issued:v1</code>, with a unique index on source, template and recipient. A replayed bill cannot queue a second message.</p>
<h2 id="never-add-bill-totals">Never add bill totals</h2>
<p>Every amount a customer owes lives in one table, <code>ledger_entries</code>. Bill charges and payment reversals are debits. Payments and voids are credits. The balance is the sum. The API&#39;s database role has only <code>SELECT</code> and <code>INSERT</code> on that table, and a trigger refuses the rest:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TRIGGER</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ledger_entries_append_only</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEFORE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> UPDATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> OR</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DELETE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ledger_entries</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FOR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> EACH </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> EXECUTE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">prevent_ledger_mutation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- raises: 'ledger entries are append-only; post a reversal entry instead'</span></span></code></pre></figure>
<p>That design still let one bug through, and an accounting audit caught it. A bill&#39;s total includes the customer&#39;s whole balance as arrears, which is what the customer needs to see on the bill. Yet every older unpaid bill stayed payable, and each payment was checked against its own bill only.</p>
<p>Bill A: 1,000, unpaid. Bill B: 500 + 1,000 arrears = 1,500. A payment of 1,000 on A and a payment of 1,500 on B were both accepted, for a real debt of 1,500. Two meters, or a voided bill, opened the same hole.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>Before migration 0080</span><span>After</span></div><div class="dg-compare-row"><span>Bill A: 1,000, open for payment</span><span>Bill A carried into bill B</span></div><div class="dg-compare-row"><span>Bill B: 500 + 1,000 arrears = 1,500, also open</span><span>Only bill B takes payments</span></div><div class="dg-compare-row"><span>Paying both collects 2,500 for a debt of 1,500</span><span>Payment capped at the ledger balance, 1,500</span></div></div></figure>
<p>Migration 0080 changed three rules. Issuing a bill carries every open bill of the customer into it, and a carried bill takes no payment. A payment is capped at the bill&#39;s remaining amount and at the customer&#39;s ledger balance. After any money change, the database sets every bill&#39;s status from the ledger, oldest debt first, so paid or unpaid is never worked out from <code>paid_amount</code> against <code>total_amount</code>.</p>
<p>The same mistake was hiding in a dashboard. The website&#39;s dashboard added up a page of 250 rows to show what was billed and owed. Now the database sums every row the member is allowed to see, and the dashboard reads that one number.</p>
<aside class="callout callout-warning"><p class="callout-label">Watch out</p><p>A bill&#39;s total contains older debt. Adding totals counts the same debt more than once. Reports add charges, and balances come from the ledger.</p>
</aside>
<h2 id="your-status-codes-are-your-retry-policy">Your status codes are your retry policy</h2>
<p>Back to the outbox. When an upload fails, the phone must decide what the failure means. It sorts every error into four kinds:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Kind</th>
<th>Examples</th>
<th>What the outbox does</th>
</tr>
</thead>
<tbody><tr>
<td>Deferred</td>
<td>no network, signed out, phone awaiting approval</td>
<td>stops this run and keeps everything for later</td>
</tr>
<tr>
<td>Retryable</td>
<td>timeouts, 408, 429, 500, 502, 503, 504, <code>OPERATION_IN_PROGRESS</code></td>
<td>keeps the command and tries again on a later run</td>
</tr>
<tr>
<td>Blocked</td>
<td>400, 403, 404, 409, 410, 412, 422, 501</td>
<td>stops sending it and shows it to a person for review</td>
</tr>
<tr>
<td>Unknown</td>
<td>anything else</td>
<td>stops this run and tries again later</td>
</tr>
</tbody></table></div>
<p>Read the 500 in that table once more. Now the night of 18 October.</p>
<p>The receipt carried a payment provider&#39;s transaction number, the reference of a transfer. That number was already on another live receipt from the same provider. A unique index, <code class="code-long">payments_provider_reference_unique</code>, refused the insert, exactly as designed. The API had no mapping for that error, so it surfaced as a 500. The phones read a 500 as a passing failure, and sent the receipt again.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>The phone sends the receipt</strong><span>from its outbox, with its key</span></li><li><strong>The index refuses it</strong><span>the transaction number is already on a live receipt</span></li><li><strong>The API answers 500</strong><span>the claim rolls back with everything else</span></li><li><strong>The phone retries</strong><span>a 500 is a passing failure</span></li></ol></figure>
<p>Now the question from the opening. Why did the idempotency layer not stop it?</p>
<p>Because a key replays successes. The claim lives in the same transaction as the work, and that is exactly what makes it safe: the work and its stored response commit together or not at all. When the insert failed, PostgreSQL aborted the transaction and the claim vanished with it. Nothing remembered the failure, so every resend was a first attempt that failed the same way. Storing failures would be worse, because a failure that is only passing, a deadlock or a restart, would then be replayed forever.</p>
<p>The key did its job. The status code was the bug. The fix is one function: that unique violation becomes a 409, which the station app and the website explain in plain Arabic. On the phone a 409 is blocked, shown for review and never resent.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> duplicateReference</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">error</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">unknown</span><span style="--shiki-light:#999999;--shiki-dark:#666666">):</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> never</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">failure</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> error</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> code</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">unknown</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> constraint</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">unknown</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> |</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">failure</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">code</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">23505</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> failure</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">constraint</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">payments_provider_reference_unique</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    throw</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ConflictException</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({ </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">code</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">PAYMENT_REFERENCE_DUPLICATE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  }</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  throw</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> error</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>The commit that fixed it is timestamped 22:29 the same night. Its integration test sends the same command twice with the same key and expects the same 409 both times. It also checks that a different provider may carry the same number.</p>
<aside class="callout callout-tip"><p class="callout-label">Tip</p><p>List every unique constraint in your schema. Each one is a 409 waiting to be mapped. Left unmapped, it answers 500, and an offline client will retry it until someone notices.</p>
</aside>
<h2 id="late-writes-land-in-the-open-period">Late writes land in the open period</h2>
<p>Offline phones raise one more question. What if a phone syncs a receipt dated inside a period the station has already closed? Closing a period writes, for every customer, a closing entry and an opening entry that sum to zero, and the closed period has to keep summing to zero. So a trigger moves a late entry to the start of the open period:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.ledger_entry_into_open_period</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> RETURNS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> trigger </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">DECLARE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> open_from </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">timestamptz</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEGIN</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  PERFORM pg_advisory_xact_lock_shared(hashtextextended(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">archive:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">::</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">text</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">));</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  open_from :</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">archive_boundary</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">);</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  IF</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">effective_at</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> open_from </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AND</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">entry_kind</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">::</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">text</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">period_close</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> THEN</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">    NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">effective_at</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> :</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> open_from;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  END</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> IF</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  RETURN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> NEW;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $$ </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LANGUAGE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> plpgsql;</span></span></code></pre></figure>
<p>Closing takes the same advisory lock exclusively, per station. A close waits for the writes in progress, and new writes wait for the close.</p>
<h2 id="the-checklist-for-offline-money">The checklist for offline money</h2>
<ul>
<li>Give each command its id when it is first saved on the device, and keep it until the server confirms.</li>
<li>Give commands queued before you had ids a derived, stable id. Never a fresh one per retry.</li>
<li>Make that id the identity of what the command creates: the bill&#39;s id, the payment&#39;s id.</li>
<li>Claim the key before the work, in the same transaction, and store the response with it.</li>
<li>Make a key single-use per actor across every endpoint, and reject it when the body differs.</li>
<li>Fail closed when a receipt exists and cannot be read.</li>
<li>Let the phone send facts, such as a meter reading. Let the server compute every amount.</li>
<li>Give side effects their own keys, like <code>invoice:&lt;id&gt;:issued:v1</code>.</li>
<li>Keep balances in an append-only ledger, and never add bill totals.</li>
<li>Map every unique constraint to a 4xx. Your status codes are your retry policy.</li>
<li>Move late entries into the open period and leave the closed one closed.</li>
</ul>
<p>The network will keep dropping, and phones will keep resending. The balance has to be right anyway, and a refusal has to stay a refusal.</p>
<p>This is how I build <a href="https://moghalis.com/services/mobile-apps">mobile apps</a> that have to work offline.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Security that survives my own bugs: six layers and their configs</title>
      <link>https://moghalis.com/blog/securing-client-systems</link>
      <guid isPermaLink="true">https://moghalis.com/blog/securing-client-systems</guid>
      <pubDate>Mon, 19 Oct 2026 14:00:00 GMT</pubDate>
      <description>Six layers in production, each built for the day the one above it fails: forced row-level security, logins that cannot bypass it, sealed containers, phone certificates, weekly restores.</description>
      <category>security</category>
      <category>PostgreSQL</category>
      <category>Docker</category>
      <category>Caddy</category>
      <category>mTLS</category>
      <category>backups</category>
      <content:encoded><![CDATA[<p>FinyxWatt, my platform for electricity and water stations, keeps the customers, bills and receipts of many stations in one PostgreSQL database. Every query its API sends is one missing <code>WHERE tenant_id = ?</code> away from showing one station&#39;s customers to another. I write those queries. One day I will forget the filter.</p>
<p>So nothing depends on me remembering it.</p>
<p>This is the field manual for how FinyxWatt and the server behind this site are protected: six layers, each built on the assumption that the layer above it has already failed. Every config comes from the real files, trimmed, without secrets or hostnames. By the end you can make PostgreSQL refuse a read your code forgot to filter, run containers that can barely do anything, give a phone an identity it cannot fake, and prove every week that your backup restores.</p>
<p>Three traps wait along the way: a PostgreSQL setting that comes back as an empty string, a header anyone could forge if one container joined the wrong network, and a retry default that lets a broken backup hold its own test hostage for hours.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>Tenant isolation lives inside PostgreSQL: row-level security, forced, bound to an active membership, and met by logins that cannot bypass it.</li>
<li>Every process, container and phone holds only what its one job needs. Secrets arrive as files and never pass through Git.</li>
<li>A backup counts once it has been restored. Every Friday the newest one is rebuilt in a container with no network, and counted.</li>
</ul>
</aside>
<figure class="post-figure dg dg-is-stack"><ol class="dg-stack"><li><strong>6. Server</strong><span>a firewall for SSH, HTTP and HTTPS only, key-only SSH, automatic security updates</span></li><li><strong>5. Front door (Caddy)</strong><span>HTTPS, security headers, closed admin paths, phone certificates</span></li><li><strong>4. Containers</strong><span>read-only, no Linux capabilities, internal networks, secrets as files</span></li><li><strong>3. API</strong><span>verified tokens; the station comes from the token, never the request</span></li><li><strong>2. Database roles</strong><span>no superuser, no bypass, an insert-only ledger and audit log</span></li><li><strong>1. Row-level security</strong><span>every row bound to an active membership of the user's station</span></li></ol></figure>
<h2 id="layer-1-rows-that-refuse-the-wrong-station">Layer 1: rows that refuse the wrong station</h2>
<p>The usual tenant boundary is a <code>WHERE</code> clause repeated in every query. It holds until one query, written in a hurry, leaves it out, and the bug is silent: the page works, it just shows too much.</p>
<p>FinyxWatt puts the boundary inside the database. Row-level security is enabled and forced on 88 of the 94 tables in its migrations. The six without it hold the permission catalogue, schema metadata and the bookkeeping of a one-time import. Every policy checks the row&#39;s station and that the user is an active member of it:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ALTER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TABLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ENABLE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LEVEL</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> SECURITY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ALTER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TABLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FORCE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LEVEL</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> SECURITY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> POLICY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tenant_scope_api </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FOR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ALL </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  USING</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    tenant_id </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">current_tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    AND</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> EXISTS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      SELECT</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tenant_memberships m</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      WHERE</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> m</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">   =</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">current_tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">        AND</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> m</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">identity_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">current_identity_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">        AND</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> m</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">status</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">active</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    )</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  )</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  WITH</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ( </span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* the same condition */</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> );</span></span></code></pre></figure>
<p>The migration that added the membership check opens with its reason: &quot;a future coding error must not turn a verified identity from tenant A into tenant B merely by setting a wrong transaction setting.&quot; A wrong station id finds no active membership, and the query returns nothing.</p>
<p>The API sets both values at the start of every transaction, from the verified token:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">async</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> withTenant</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">T</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tenantId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> identityId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> callback</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Transaction</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Database</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> Promise</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">T</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">transaction</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">async</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sql</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">select set_config('app.tenant_id', </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tenantId</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, true)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sql</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">select set_config('app.identity_id', </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">identityId</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, true)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> callback</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  })</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>Three details decide whether this works.</p>
<p><strong>The final <code>true</code></strong> makes the setting local to the transaction. A plain <code>SET</code> stays on the connection, and the pool hands it, still pointing at station A, to the next request.</p>
<p><strong>The empty string</strong> is the first trap. On a new connection, a setting that was never defined reads as <code>NULL</code>. On a pooled connection that has already served a request, it reads as <code>''</code>, and <code>''::uuid</code> throws. The helper every policy calls handles both:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.current_tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> RETURNS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> uuid</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LANGUAGE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> sql</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> STABLE PARALLEL </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SAFE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> nullif</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(current_setting(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">app.tenant_id</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, true), </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">''</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">)::uuid;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$;</span></span></code></pre></figure>
<p><code>NULL</code> equals nothing, so a request without context sees zero rows. It fails closed.</p>
<p><strong><code>FORCE</code></strong> matters because a table&#39;s owner skips its policies by default. Superusers and <code>BYPASSRLS</code> roles skip them always. That is layer 2&#39;s job.</p>
<aside class="callout callout-note"><p class="callout-label">Note</p><p>The migration&#39;s header states the limit plainly: &quot;These settings defend against tenant-filter omissions; they do not make arbitrary SQL execution safe.&quot; Parameterised queries are still required everywhere.</p>
</aside>
<p>CI applies every migration to a fresh PostgreSQL and runs adversarial SQL tests as the API&#39;s own role. The core of one, ready to copy:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LOCAL</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">   =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;station A></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LOCAL</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">identity_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;an active member of station A></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">DO $test$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEGIN</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  IF</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> customers) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> THEN</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    RAISE EXCEPTION </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">RLS expected one station-A customer</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  END</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> IF</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  BEGIN</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    INSERT INTO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> customers (tenant_id, subscriber_number, display_name)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    VALUES</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;station B></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ILLEGAL</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Cross-tenant write</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">);</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    RAISE EXCEPTION </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">RLS accepted a cross-tenant write</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  EXCEPTION </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">WHEN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> insufficient_privilege </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">THEN</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NULL</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $test$;</span></span></code></pre></figure>
<p>All of this assumes the API connects as a role the policies apply to.</p>
<h2 id="layer-2-logins-that-cannot-switch-it-off">Layer 2: logins that cannot switch it off</h2>
<p>Connect as the owner without <code>FORCE</code>, as a superuser or as a <code>BYPASSRLS</code> role, and every policy above is decoration.</p>
<p>FinyxWatt&#39;s database creates its logins on first start, &quot;before the API image is ever allowed to connect&quot;, in the words of the script. Trimmed; the real one takes names from the environment and passwords from secret files:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> migrator  </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LOGIN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> NOSUPERUSER NOCREATEDB CREATEROLE   NOREPLICATION NOBYPASSRLS;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> api_login </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LOGIN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ALTER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DATABASE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> app </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">OWNER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> migrator;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">REVOKE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> CONNECT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DATABASE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> app </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> PUBLIC;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">GRANT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  CONNECT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DATABASE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> app </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> migrator, api_login;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">GRANT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> api_login;   </span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- the role every policy is written for</span></span></code></pre></figure>
<p>Only the migration login owns the schema. No runtime login holds a migration password, and the API runs with <code>RUN_MIGRATIONS: &quot;false&quot;</code>. Five runtime roles split the work:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Role</th>
<th>May</th>
<th>Cannot</th>
</tr>
</thead>
<tbody><tr>
<td><code>platform_api</code></td>
<td>station data through the policies; append to ledger and audit log</td>
<td>edit or delete a ledger entry or audit event</td>
</tr>
<tr>
<td><code>platform_worker</code></td>
<td>deliver queued notifications and SMS jobs</td>
<td>share a login or a password with the API</td>
</tr>
<tr>
<td><code>platform_control</code></td>
<td>the platform&#39;s control plane</td>
<td>act without a platform identity in the transaction</td>
</tr>
<tr>
<td><code>platform_auditor</code></td>
<td>read the audit log and change log</td>
<td>write anything</td>
</tr>
<tr>
<td><code>platform_sms_enrollment</code></td>
<td>one-time codes and pending phone requests</td>
<td>see SMS jobs, devices, audit records or customers</td>
</tr>
</tbody></table></div>
<p>The ledger and the audit log refuse changes twice:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">GRANT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">INSERT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ledger_entries </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api;   </span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- no UPDATE, no DELETE</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.prevent_ledger_mutation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> RETURNS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> trigger</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LANGUAGE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> plpgsql </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SECURITY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> INVOKER </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> search_path </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> pg_catalog, public </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEGIN</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  RAISE EXCEPTION </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ledger entries are append-only; post a reversal entry instead</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    USING</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ERRCODE </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">integrity_constraint_violation</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TRIGGER</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ledger_entries_append_only</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEFORE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> UPDATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> OR</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DELETE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ledger_entries</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FOR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> EACH </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> EXECUTE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">prevent_ledger_mutation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span></code></pre></figure>
<p>A later migration can widen a grant by accident. The trigger still refuses, and its message tells the next developer what to do instead.</p>
<aside class="callout callout-tip"><p class="callout-label">Tip</p><p>Run these two queries on your database today. The first should list only admin logins no application uses. Every table in the second should be one you left out on purpose.</p>
</aside>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- Logins that skip row-level security</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rolname </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> pg_roles</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">WHERE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rolcanlogin </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AND</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (rolsuper </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">OR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rolbypassrls);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- Tables without enabled AND forced row-level security</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relname </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> pg_class</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">WHERE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relkind </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">r</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AND</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relnamespace </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">public</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">::regnamespace</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  AND</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (relrowsecurity </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AND</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relforcerowsecurity);</span></span></code></pre></figure>
<p>Roles decide what a login may do. Which station it acts for comes from a token.</p>
<h2 id="layer-3-an-api-that-believes-only-what-it-verified">Layer 3: an API that believes only what it verified</h2>
<p>FinyxWatt signs people in with Keycloak. The API checks every token&#39;s signature, issuer, audience and expiry against Keycloak&#39;s published keys:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">jwks</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> createRemoteJWKSet</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> URL</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">keycloakJwksUri</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> jwtVerify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">token</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">jwks</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  issuer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">keycloakIssuer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  audience</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">keycloakAudience</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">})</span></span></code></pre></figure>
<p>Then it reads the verified <code>azp</code> claim to learn which client obtained the token. The comment above that check is the whole lesson of this layer: &quot;Never accept a client-type header: a caller could forge one to route an Android bearer around a cookie-only endpoint.&quot;</p>
<p>The station follows the same rule. The API accepts only memberships of the verified identity, then reads that membership again under row-level security. Sessions are short and single-use:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">accessTokenLifespan</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">300</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">revokeRefreshToken</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">true</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">refreshTokenMaxReuse</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">bruteForceProtected</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">true</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">failureFactor</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">5</span></span></code></pre></figure>
<p>Access tokens live five minutes. A refresh token works once, so a copied one is spent the moment either side uses it. Five wrong passwords make the account wait.</p>
<p>So far the attacker has been a bug in my code. The next layers assume the attacker is already inside a container.</p>
<h2 id="layer-4-containers-that-can-barely-do-anything">Layer 4: containers that can barely do anything</h2>
<p>This site runs in one Caddy container, defined like this:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">web</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy:2.10.2-alpine</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  ports</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">80:80</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443/udp</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  volumes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ./Caddyfile:/etc/caddy/Caddyfile:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis:/srv/moghalis:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_data:/data</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_config:/config</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  read_only</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  tmpfs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/tmp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  cap_drop</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ALL</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  cap_add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">NET_BIND_SERVICE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  security_opt</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">no-new-privileges:true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  mem_limit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 128m</span></span></code></pre></figure>
<p>Nothing can be written outside <code>/tmp</code> and the certificate volumes, every Linux capability is gone except binding low ports, no process can gain new privileges, and memory stops at 128 MB.</p>
<p>FinyxWatt runs twenty services, each with <code>no-new-privileges</code>; its Caddy is read-only with every capability dropped. The stronger protection is the network map:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">networks</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  edge</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  application</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  database</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  observability</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  sms_gateway</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  egress</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span></code></pre></figure>
<p>An <code>internal</code> network has no route out, and each service joins only what it needs:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Service</th>
<th>Networks</th>
</tr>
</thead>
<tbody><tr>
<td>Caddy, the front door</td>
<td>edge, application, sms_gateway, observability</td>
</tr>
<tr>
<td>API</td>
<td>application, database, egress, observability</td>
</tr>
<tr>
<td>SMS gateway</td>
<td>sms_gateway, database</td>
</tr>
<tr>
<td>Phone enrolment</td>
<td>application, database</td>
</tr>
<tr>
<td>Web front end, file storage</td>
<td>application</td>
</tr>
</tbody></table></div>
<p>The front door has no route to the database, and the services in the last three rows have no route to the internet.</p>
<p>FinyxWatt&#39;s production stack needs 28 secrets. Each is a separate file with its own random value, mounted as a Docker secret only into the containers that need it:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">api</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  environment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    DB_USER</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ${PLATFORM_API_USER}</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    DB_PASSWORD_FILE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /run/secrets/platform_api_password</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  secrets</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> platform_api_password</span></span></code></pre></figure>
<p>The secrets guide puts the rule in one sentence: &quot;Do not put secrets in command-line arguments, <code>.env</code> files, shell history, container images, or source control.&quot; An environment variable shows up in <code>docker inspect</code>; a file under <code>/run/secrets</code> exists only where it was mounted.</p>
<h2 id="layer-5-a-front-door-that-answers-less">Layer 5: a front door that answers less</h2>
<p>Caddy on this site&#39;s server renews HTTPS certificates and adds these headers to every response:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>header {</span></span>
<span class="line"><span>	Strict-Transport-Security "max-age=31536000; includeSubDomains"</span></span>
<span class="line"><span>	X-Content-Type-Options "nosniff"</span></span>
<span class="line"><span>	X-Frame-Options "SAMEORIGIN"</span></span>
<span class="line"><span>	Referrer-Policy "strict-origin-when-cross-origin"</span></span>
<span class="line"><span>	Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"</span></span>
<span class="line"><span>	-Server</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p>FinyxWatt&#39;s front door also closes what it does not serve:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>{</span></span>
<span class="line"><span>	admin off</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span></span></span>
<span class="line"><span>(restricted_console) {</span></span>
<span class="line"><span>	@outside_trusted_networks not remote_ip {$ADMIN_TRUSTED_CIDRS}</span></span>
<span class="line"><span>	respond @outside_trusted_networks 403</span></span>
<span class="line"><span>	basic_auth {</span></span>
<span class="line"><span>		{$OBSERVABILITY_USER} {$OBSERVABILITY_PASSWORD_HASH}</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p><code>admin off</code> turns off Caddy&#39;s own admin API. Monitoring consoles answer only from trusted networks, then ask for a password the server holds only as a bcrypt hash. <code>/metrics</code> returns 404 from the internet, and the public phone-enrolment route accepts 32 KB at most, because it never receives a file. Each security header comes from one place: the API sets <code>X-Frame-Options</code> itself, so Caddy&#39;s block for the API leaves it out.</p>
<p>The front door&#39;s most interesting job is checking a passport for every phone that sends bills.</p>
<h2 id="a-passport-for-every-phone">A passport for every phone</h2>
<p>FinyxWatt sends each customer&#39;s bill by SMS from dedicated Android phones at the station. A phone that sends messages in a station&#39;s name needs more than a password, so it gets a client certificate.</p>
<figure class="post-figure dg dg-is-timeline"><ol class="dg-timeline"><li><strong>On the phone</strong><span>a key is created in the Android Keystore and never leaves it</span></li><li><strong>To the enrolment service</strong><span>a one-time code and proof of the key create a pending request, nothing more</span></li><li><strong>A second manager reviews</strong><span>the code's creator cannot approve; approval binds a certificate to the proven key</span></li><li><strong>Back on the phone</strong><span>the certificate is installed</span></li><li><strong>Every connection</strong><span>Caddy requires and verifies the certificate, then writes the fingerprint header itself</span></li></ol></figure>
<p>The database keeps only an HMAC of the code, made with a secret pepper, and the schema enforces the limits itself:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- HMAC-SHA-256(code-id || secret), never the raw enrollment code.</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">code_digest  </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">bytea</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (octet_length(code_digest) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 32</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">),</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">expires_at   </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">timestamptz</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">max_attempts </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">smallint</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> DEFAULT</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (max_attempts </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BETWEEN</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AND</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">),</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">attempts     </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">smallint</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> DEFAULT</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (attempts </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BETWEEN</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AND</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CONSTRAINT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> sms_device_enrollment_codes_attempts_bound </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (attempts </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> max_attempts),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CONSTRAINT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> sms_device_enrollment_codes_expiry </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (expires_at </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">></span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> created_at)</span></span></code></pre></figure>
<p>Approval goes through the main API, which refuses the code&#39;s creator with <code class="code-long">SMS_ENROLLMENT_SELF_APPROVAL_FORBIDDEN</code> and binds a certificate from a private authority whose public key matches the key the phone proved it holds. From then on:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>tls {</span></span>
<span class="line"><span>	client_auth {</span></span>
<span class="line"><span>		mode require_and_verify</span></span>
<span class="line"><span>		trust_pool file /run/secrets/sms_device_ca_pem</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span>reverse_proxy sms-gateway:8081 {</span></span>
<span class="line"><span>	header_up X-Verified-Client-Certificate-Fingerprint {tls_client_fingerprint}</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p>Now the second trap. The gateway believes that fingerprint header without question, and that is safe for one reason: it sits on an internal network that only Caddy joins, and Caddy overwrites the header after verifying the certificate. The gateway&#39;s own code spells out how to break it: &quot;Do not expose the gateway container port or add arbitrary containers to that network; doing either would make this header forgeable.&quot; One <code>ports:</code> line added for a quick debug, and anyone could send the header.</p>
<p>The proxy hides one more trap. Every request reaches the gateway with Caddy&#39;s address, so a per-IP rate limit would give all phones one shared budget. The gateway keys its limit on the verified certificate:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> app</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">register</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fastifyRateLimit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">120</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  timeWindow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">1 minute</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  keyGenerator</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">) => {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fingerprint</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">headers</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">x-verified-client-certificate-fingerprint</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">normalized</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> typeof </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fingerprint</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> === </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">string</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ? </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fingerprint</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">trim</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toLowerCase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">replaceAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ''</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> : </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">''</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">^</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">0-9a-f</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{64}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">$</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">test</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">normalized</span><span style="--shiki-light:#999999;--shiki-dark:#666666">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">device:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">normalized</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">addr:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ip</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  },</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">})</span></span></code></pre></figure>
<p>The authority&#39;s private key stays offline, and each phone&#39;s certificate is revoked when the device is.</p>
<h2 id="layer-6-a-server-you-cannot-lock-yourself-out-of">Layer 6: a server you cannot lock yourself out of</h2>
<p>This site&#39;s server was set up by one script, and its SSH part is careful about order:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -s</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/home/$USER_NAME/.ssh/authorized_keys</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ];</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> then</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  cat</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/etc/ssh/sshd_config.d/10-moghalis.conf</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'EOF'</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">PermitRootLogin no</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">PasswordAuthentication no</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">KbdInteractiveAuthentication no</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">EOF</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  sshd</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x26;&#x26;</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> systemctl</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> reload</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ssh</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">else</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! No SSH key for $USER_NAME yet: password login left ON.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">fi</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> default</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> deny</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> incoming</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> OpenSSH</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 80/tcp</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 443/tcp</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 443/udp</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --force</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> enable</span></span></code></pre></figure>
<p>Password login goes off only when a key is in place, SSH reloads only when <code>sshd -t</code> accepts the config, and the firewall allows SSH before it goes up. Get any of the three out of order on a fresh VPS and your next stop is the provider&#39;s rescue console.</p>
<p>Six layers, each assuming the one above it fails. The backup assumes they all did.</p>
<h2 id="beside-the-stack-a-backup-that-proves-it-restores">Beside the stack: a backup that proves it restores</h2>
<p>FinyxWatt&#39;s PostgreSQL archives its write-ahead log at least every five minutes and takes a full base backup every night with WAL-G, whose binary is pinned and checksum-verified. Every file is compressed and encrypted before it is written. The owner&#39;s office computer pulls new files every four hours and keeps 45 days of them.</p>
<p>The encryption key is the backup: lose it and every file is noise. It is sealed with the other server secrets in a password-protected file, on a USB drive and in a second place, with the password kept apart from both.</p>
<p>None of that proves anything until a restore works. Every Friday a script restores the newest base backup and every archived log after it into a throwaway volume. Trimmed:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">timeout</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --kill-after=60</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 2700</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> docker</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> run</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --network</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> none</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --name</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$name</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -e</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> PITR_STORAGE_MODE=local</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -e</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> WALG_DOWNLOAD_FILE_RETRIES=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$name:/restore</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$repository:/local-pitr:ro</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$key:/run/secrets/pitr_encryption_key:ro</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  --entrypoint</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> bash</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$image</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /restore-check.sh</span></span></code></pre></figure>
<p>Inside, the copy&#39;s config gets these lines before PostgreSQL starts:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">cat</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> >></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /restore/data/postgresql.auto.conf</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'CONF'</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">restore_command = '/usr/local/bin/pitr-walg wal-fetch %f %p'</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">archive_mode = off</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">listen_addresses = ''</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">CONF</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">touch</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /restore/data/recovery.signal</span></span></code></pre></figure>
<p>Each line is a guard. No network, so the copy cannot touch the live database. A read-only repository and key. No archiving, no listeners. And <code>$image</code> is read from the live database container, so the check uses the exact image production runs. When recovery finishes, it counts what came back:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> json_build_object(</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">migrations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,   (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> schema_migrations),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">stations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,     (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tenants),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">customers</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,    (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> customers),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">bills</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,        (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">receipts</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,     (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> payments),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">newestChange</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> max</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(changed_at) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> change_log));</span></span></code></pre></figure>
<p>The counts say the backup holds what it should; the newest change says how fresh it is. Then everything is deleted, whatever happened. A failed check, or no check for nine days, raises an alert in my developer app.</p>
<p>Now the third trap, and the reason for <code>WALG_DOWNLOAD_FILE_RETRIES=2</code>. WAL-G retries a file it cannot read 15 times, minutes apart. Point it at a damaged or undecryptable backup and the check does not fail. It waits, for hours. Two retries and a 45-minute ceiling turn that wait into a clear failure with a reason.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow is-long"><li><strong>Encrypted backup</strong><span>newest base backup and every WAL file after it, read-only</span></li><li><strong>Sealed container</strong><span>no network, no archiving, no listeners, 45 minutes at most</span></li><li><strong>PostgreSQL recovery</strong><span>rebuilt with the image production runs</span></li><li><strong>Count what came back</strong><span>migrations, stations, customers, bills, receipts, newest change</span></li><li><strong>Clean up</strong><span>container and volume deleted; the result goes to the watchdog</span></li></ol></figure>
<p>A watchdog runs every ten minutes and reports what quietly breaks backups: a disk at 85%, a stopped service, a full backup older than 26 hours, stuck WAL archiving, an office computer that has not pulled for three days, a certificate close to expiry.</p>
<aside class="callout callout-warning"><p class="callout-label">Watch out</p><p>Do not quote a recovery time you have not measured. FinyxWatt&#39;s recovery runbook forbids claiming its target recovery figures until a timed recovery in the real repository has passed.</p>
</aside>
<h2 id="the-gate-in-front-of-every-release">The gate in front of every release</h2>
<p>The layers protect the running system. The gate protects it from the next release.</p>
<p>A repository check fails the build on private keys, tokens, JWTs, database URLs with a password, real emails and phone numbers, and any tracked <code>.env</code> or <code>secrets/</code> file:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">private key material</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">-----BEGIN</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(?:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">A-Z</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">?</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F"> PRIVATE KEY-----</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">GitHub access token</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">\b</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">gh</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">pousr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">_</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">A-Za-z0-9_</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{30,}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">\b</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">database URI with embedded password</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">\b</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">postgres</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(?:</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">ql</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">?</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">:</span><span style="--shiki-light:#BDA437;--shiki-dark:#E6CC77">\/\/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">^</span><span style="--shiki-light:#5A6AA6;--shiki-dark:#6872AB">\s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">:@/${</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">^</span><span style="--shiki-light:#5A6AA6;--shiki-dark:#6872AB">\s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">@/${</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{8,}</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">@</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span></span></code></pre></figure>
<p>It lists files with <code class="code-long">git ls-files --cached --others --exclude-standard</code>, so run locally it catches a file before you add it.</p>
<p>Then CI runs every migration on a fresh PostgreSQL with the tenant-isolation tests, the backend tests, integration tests under the restricted database role, and <code class="code-long">npm audit --omit=dev --audit-level=high</code>. Automatic deployment waits for all of it:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">on</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  workflow_run</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    workflows</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Independent Platform CI</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    types</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">completed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    branches</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">main</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">concurrency</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  group</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> production-deploy</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  cancel-in-progress</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> false</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">jobs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  deploy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> github.event.workflow_run.conclusion == 'success'</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> uses</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> actions/checkout@v4</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">        with</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">          ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ${{ github.event.workflow_run.head_sha }}</span></span></code></pre></figure>
<p>Three traps live in those lines. <code>workflow_run</code> fires when CI completes, pass or fail, so without the <code>if</code> a red build deploys. Checking out <code>main</code> instead of <code>head_sha</code> can ship a commit CI never tested. And a deployment cancelled halfway leaves a half-migrated server. The job also pins the server&#39;s host-key fingerprint before its first SSH command.</p>
<h2 id="attack-it-yourself-on-paper">Attack it yourself, on paper</h2>
<p>A written review attacked FinyxWatt through its code and with passive requests to the live servers, recording each result with the file that proves it:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Attempt</th>
<th>What stops it</th>
</tr>
</thead>
<tbody><tr>
<td>A forged token: <code>alg: none</code>, or another issuer</td>
<td><code>jose</code>, the published keys, issuer, audience, <code>azp</code></td>
</tr>
<tr>
<td>Acting for another station through a membership header</td>
<td>memberships of the verified identity only, re-read under RLS</td>
</tr>
<tr>
<td>Reading another station&#39;s change feed</td>
<td>row-level security on the change log</td>
</tr>
<tr>
<td>A cross-site POST against the web session</td>
<td>an origin check and <code>SameSite=Strict</code> cookies</td>
</tr>
<tr>
<td>CORS reflection with a hostile <code>Origin</code></td>
<td>no <code>Access-Control-Allow-Origin</code> in the reply, checked live</td>
</tr>
<tr>
<td>A forged certificate fingerprint header</td>
<td>unpublished port; Caddy overwrites the header</td>
</tr>
<tr>
<td>Guessing a phone&#39;s pairing code</td>
<td>five tries, HMAC with a pepper, an expiry, a row lock</td>
</tr>
<tr>
<td>Reaching <code>/metrics</code> or Keycloak&#39;s console from outside</td>
<td>404 and 403, checked live</td>
</tr>
</tbody></table></div>
<h2 id="the-checklist">The checklist</h2>
<ul>
<li>Row-level security forced on every tenant table; policies check an active membership.</li>
<li>Tenant context per transaction with <code>set_config(..., true)</code>, from the token, read through <code>nullif</code>.</li>
<li>No application login is owner, superuser or <code>BYPASSRLS</code>; one login per job.</li>
<li>Ledger and audit tables: no <code>UPDATE</code> or <code>DELETE</code> grants, plus a trigger that refuses both.</li>
<li>Secrets as files, mounted only where needed; CI fails on keys, tokens and tracked <code>.env</code> files.</li>
<li>Containers read-only, capability-free, memory-capped, on internal networks.</li>
<li>Identity headers trusted only on a closed network; rate limits keyed on that identity.</li>
<li>Encrypted backups, the key kept apart, a copy off the server, a timed restore check with an alert.</li>
<li>Deploys only after green CI, from the tested commit, never cancelled halfway.</li>
</ul>
<p>None of these layers is clever on its own. The strength is the assumption under all of them: the layer above has already failed, and this one has to hold anyway. Write that assumption into your configs, and the day one of them fails becomes an ordinary day.</p>
<p>If you would rather have someone run this for your system, that is what <a href="https://moghalis.com/services/hosting-and-support">hosting and support</a> covers.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The final payment should be the easy one. How I run client projects</title>
      <link>https://moghalis.com/blog/how-i-deliver-projects</link>
      <guid isPermaLink="true">https://moghalis.com/blog/how-i-deliver-projects</guid>
      <pubDate>Mon, 19 Oct 2026 13:00:00 GMT</pubDate>
      <description>How I run client projects so the client pays, trusts me and comes back: a written proposal with phases, every phase live on their server, tests they can read, and a handover pack.</description>
      <category>freelancing</category>
      <category>proposals</category>
      <category>process</category>
      <category>testing</category>
      <category>handover</category>
      <content:encoded><![CDATA[<p>It is the last week of a freelance project. The system runs on your laptop and nowhere else. The client has never seen it on their own server. The largest payment is still unpaid, and what was promised lives in a chat thread from months ago. Every demo from here on is a negotiation.</p>
<p>Some of the business owners who write to me have sat on the other side of that week. They paid for software and never received it in a state their staff could use.</p>
<p>I run projects so that week never comes. This is the method, written for developers and freelancers. By the end you will have the outline of a proposal that settles most arguments before they start, a phase loop that puts working software on the client&#39;s server from the first phase, a way to make your tests part of what the client buys, a handover pack, and the questions good clients ask, answered in writing before they ask.</p>
<p>Two things below do more work than the rest: one section of the proposal that heads off more arguments than any other, and one line in the terms that makes the final payment the easy one.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>Nothing is built before a written proposal: phases, what is included, what is not, and the terms, on a private page with a reference number and an expiry date.</li>
<li>Every phase ends live on the client&#39;s server, where their own team tests it before the next phase begins.</li>
<li>At handover the client receives the code, the documentation, the scripts that run the system and a written acceptance report.</li>
</ul>
</aside>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>Where projects go wrong</span><span>How I run them</span></div><div class="dg-compare-row"><span>Scope lives in a chat thread</span><span>A written proposal with phases and a not-included list</span></div><div class="dg-compare-row"><span>One big reveal at the end</span><span>Every phase live on the client's server</span></div><div class="dg-compare-row"><span>It works on the developer's laptop</span><span>The client's team tests it on their own server</span></div><div class="dg-compare-row"><span>Tests are a cost hidden in the estimate</span><span>Tests are a line in what the client buys</span></div><div class="dg-compare-row"><span>Handover is a zip file</span><span>Code, documentation, scripts and an acceptance report</span></div><div class="dg-compare-row"><span>Changes agreed in a voice note</span><span>Changes estimated and approved in writing</span></div></div></figure>
<h2 id="the-first-call-is-a-requirements-interview">The first call is a requirements interview</h2>
<p>A short description is enough to start: what the business does, who will use the system, and what is not working today. My contact form also asks for the type of project and a rough budget range, and &quot;not sure yet&quot; is one of the answers.</p>
<p>Then I map the business. Three questions carry most of the weight:</p>
<ul>
<li><strong>Who uses the system?</strong> Reception, accountants, branch managers, drivers, customers.</li>
<li><strong>What data matters?</strong> Invoices, stock, patient files, readings, cash boxes.</li>
<li><strong>Who may do what?</strong> Who can cancel an invoice, who sees cost prices, who approves a refund.</li>
</ul>
<p>The third question decides more of the design than any screen. On a network operations platform I built, the answer became 49 separate permissions grouped into four roles. On an ordering platform for a clothing retailer, the central rule was that a customer must never see another tier&#39;s price, and that rule is enforced inside the database with its own tests.</p>
<p>Then ask to see the tools they use today: the shared spreadsheet, the paper form, the old program. They show the real workflow better than anyone&#39;s description of it.</p>
<aside class="callout callout-tip"><p class="callout-label">Tip</p><p>Write the answer to the third question as a table, role by permission, before you draw a single screen. It becomes the roles in your database, a set of tests, and later a page of the handover pack.</p>
</aside>
<h2 id="the-proposal-six-sections-and-the-one-that-prevents-arguments">The proposal: six sections, and the one that prevents arguments</h2>
<p>Nothing is built before the client holds a written proposal. Mine follow one outline:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Section</th>
<th>What it answers</th>
</tr>
</thead>
<tbody><tr>
<td>Current situation</td>
<td>What is not working today, in the client team&#39;s own words</td>
</tr>
<tr>
<td>What we will build</td>
<td>Each part of the system, described by what it does and for whom</td>
</tr>
<tr>
<td>Phases and schedule</td>
<td>What is delivered at the end of each phase</td>
</tr>
<tr>
<td>Investment</td>
<td>The cost of each phase and when each payment is due</td>
</tr>
<tr>
<td>Included and not included</td>
<td>What the price covers, and the outside fees it does not</td>
</tr>
<tr>
<td>Terms</td>
<td>Who owns the code, how changes are handled, how long the offer stands</td>
</tr>
</tbody></table></div>
<p>The section that heads off more arguments than any other is the &quot;not included&quot; list. Server and domain fees, a WhatsApp Business account, the app stores&#39; fees: all of them are paid to other companies. Written down before the client signs, none of them can arrive later as a surprise with your name on it.</p>
<p>The &quot;included&quot; list earns its place too. In my template it reads: design, programming, automated tests, deployment on the client&#39;s server, training for the reception team, and a support period after the final handover. Automated tests are a line the client reads. That matters later.</p>
<p>Each phase has its own cost, and my template ties each payment to the start of its phase. The client never pays more than one phase ahead of software they have used, and you never work more than one phase ahead of the money.</p>
<h3 id="a-private-page-with-a-link-nobody-can-guess">A private page with a link nobody can guess</h3>
<p>The client receives the proposal as a page on my site, in Arabic or English, with a reference number and the date it stays valid until. Nothing on the site links to it. If you build the same, three rules from my build are worth copying.</p>
<p>The file name is the link, so the build refuses any name without a random tail of at least six characters:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SLUG</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">^</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">a-z0-9</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(?:</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">-</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">a-z0-9</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">*</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">-</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">a-z0-9</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{6,}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">$</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span></span></code></pre></figure>
<p>The prerender stops if a proposal page is not <code>noindex</code>. Proposals stay out of the sitemap, robots.txt disallows their paths, and the list of proposals is never bundled for the browser.</p>
<p>The server keeps search engines away and keeps the link out of referrers when the client clicks onward:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>@proposals path /p/* /ar/p/*</span></span>
<span class="line"><span>header @proposals {</span></span>
<span class="line"><span>	X-Robots-Tag "noindex, nofollow, noarchive"</span></span>
<span class="line"><span>	Referrer-Policy "no-referrer"</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<h2 id="design-while-a-change-costs-minutes">Design while a change costs minutes</h2>
<p>Before code, we agree on four things: the data the system keeps, the permissions, the connections to other systems, and the main screens.</p>
<p>This is the cheapest moment for anyone to change their mind. Moving a field on a sketch takes minutes. Changing how data is stored once the system is live is real engineering: when I moved FinyxWatt, my own platform for electricity and water stations, from Firebase to PostgreSQL, it took 107 ordered and checksummed migrations to do it while the stations kept working.</p>
<h2 id="every-phase-ends-live-on-the-clients-server">Every phase ends live on the client&#39;s server</h2>
<p>The work is split into phases, and each phase is useful on its own. In my proposal template, a clinic system is split like this:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Phase</th>
<th>What is delivered</th>
</tr>
</thead>
<tbody><tr>
<td>1. Foundation</td>
<td>Appointments, the patient file, and permissions</td>
</tr>
<tr>
<td>2. Billing</td>
<td>The invoice created from the visit, payments, reports</td>
</tr>
<tr>
<td>3. Patient app</td>
<td>Booking and reminders on Android and iOS</td>
</tr>
</tbody></table></div>
<p>Each phase ends with a version running on the client&#39;s server, or the hosting the proposal names, and their team tests it before the next phase begins. Inside a phase I work in short iterations, with working software, tests and a staging environment.</p>
<p>That order protects both sides. The client sees working software from the first phase. Their staff find the problems that matter to them while those are still cheap to fix. If priorities change, the next phase changes with them. And every payment after the first follows software the client has already used.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>Build</strong><span>short iterations, a staging environment</span></li><li><strong>Automated tests</strong><span>on every change</span></li><li><strong>Deploy</strong><span>to the client's server</span></li><li><strong>Their team tests</strong><span>fixes go back to build</span></li></ol></figure>
<p>Delivering every phase to a real server only works when a release is cheap and reversible. The network operations platform&#39;s deploy script takes a verified backup before it stops a single container, refuses to go on if the backup fails, runs the migrations and waits for the health check. The heart of this site&#39;s rollback is five lines:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">CURRENT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">readlink</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -f</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">PREVIOUS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dt</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sed</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">s:/$::</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -vx</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$CURRENT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> head</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! No earlier release to go back to.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span></code></pre></figure>
<p>It takes under a second. When going back costs that little, putting a phase on the client&#39;s server stops being a gamble.</p>
<h2 id="tests-are-part-of-what-the-client-buys">Tests are part of what the client buys</h2>
<p>An automated test is a small program that checks one rule of the business every time the code changes. Across twenty products I have written over four thousand of them.</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">4,000+</p><p class="stat-label">Automated tests across twenty products</p></div><div class="stat"><p class="stat-value" dir="ltr">1,580+</p><p class="stat-label">FinyxWatt: app and server tests</p></div><div class="stat"><p class="stat-value" dir="ltr">1,240+</p><p class="stat-label">A network operations platform: server and app tests</p></div><div class="stat"><p class="stat-value" dir="ltr">530+</p><p class="stat-label">FinyX-ERP: accounting, permissions and server</p></div></div>
<p>Tests are easy to treat as a cost hidden inside the estimate. In my template they sit in the &quot;included&quot; list, where the client reads them. The trick is to describe each one in the client&#39;s words.</p>
<p>In FinyX-ERP, my accounting and point-of-sale product, the owner can delete a posted invoice through an administrative tool. A set of tests checks that the deletion happens, that the protections which stop anyone from editing posted amounts are switched back on afterwards, and that they come back even when the deletion fails halfway. The comment at the top of the file explains the stakes: the owner asked for the deletion, so the deletion is fine. The danger is a ledger left without its guards, silently, until someone edits a posted amount.</p>
<p>Its first check is worth copying into every suite:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">The database carries no integrity triggers, so the test proves nothing.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>A test that cannot fail proves nothing, so this one confirms it can before it checks anything else. And &quot;posted amounts can never be edited, even after an administrative delete&quot; is a sentence an owner wants to read in a proposal.</p>
<p>On larger projects the tests run before any release goes out. On FinyxWatt, the app suites run on every push and pull request, and an automatic server deployment starts only after the server&#39;s suites have passed in CI. On the Bin Masoud store, every backend change runs through lint, type checks, unit tests with coverage, the database migrations and end-to-end tests.</p>
<h3 id="the-acceptance-report">The acceptance report</h3>
<p>The client&#39;s own testing comes on top, and it deserves a document. For the network operations platform, the release candidate came with a written acceptance report:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Section</th>
<th>What it shows the client</th>
</tr>
</thead>
<tbody><tr>
<td>Clean deployment</td>
<td>Deployment on a fresh Ubuntu server, step by step</td>
</tr>
<tr>
<td>Backup and restore drill</td>
<td>The backup and restore scripts, with checksum verification</td>
</tr>
<tr>
<td>Restart and reboot</td>
<td>Every service returns after a restart and after a full reboot</td>
</tr>
<tr>
<td>Integrity</td>
<td>The books balance: zero unbalanced entries, zero orphan lines, zero double reversals</td>
</tr>
<tr>
<td>Test results</td>
<td>Per project, passed and failed</td>
</tr>
<tr>
<td>Known limitations and remaining risks</td>
<td>What is not done yet, each risk with its mitigation</td>
</tr>
<tr>
<td>Verdict</td>
<td>Ready for a pilot or not, and what to do first</td>
</tr>
</tbody></table></div>
<p>The last two rows build more trust than all the others. A developer who writes down his own limitations is believed about everything else.</p>
<h2 id="the-handover-pack">The handover pack</h2>
<p>Now the line in the terms. My template says the source code belongs to the client once the final payment is made. By then their team has used every phase on their own server, so the final payment transfers ownership of a system they already run. It is the easiest payment to ask for, because nothing in it is taken on trust.</p>
<p>What they receive:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Item</th>
<th>What it holds</th>
</tr>
</thead>
<tbody><tr>
<td>Source code</td>
<td>Every repository, the client&#39;s once the final payment is made</td>
</tr>
<tr>
<td>Documentation</td>
<td>Deployment guide, operations runbook, backup and restore guide, permissions reference, API guide, architecture decisions and their reasons, security notes, troubleshooting, release notes</td>
</tr>
<tr>
<td>Scripts</td>
<td>Server setup, deployment, rollback, backups, backup verification, rotating the secret keys</td>
</tr>
<tr>
<td>Acceptance report</td>
<td>The evidence above, limitations included</td>
</tr>
<tr>
<td>Training</td>
<td>For the people who use the system every day; the reception team in the clinic template</td>
</tr>
</tbody></table></div>
<p>The documentation and script lists are what the network operations platform&#39;s repository holds. The test of a pack is simple: could the next developer run the system without calling you? A client who knows they could leave is a client who can afford to trust you.</p>
<h2 id="scope-changes-in-writing-every-time">Scope changes, in writing, every time</h2>
<p>Projects change. The term in my template takes one line: anything outside the agreed scope is estimated and approved in writing before work starts. That keeps the proposal the client signed meaningful, and it keeps you from doing free work in silence.</p>
<p>Phases make it easier. A change can be estimated and placed in the next phase, and when priorities shift, the next phase shifts with them. A written change needs five things: what changes, why, the estimate, the phase it lands in, and the client&#39;s approval.</p>
<h2 id="support-after-go-live">Support after go-live</h2>
<p>Real use always finds things testing did not. Deployment, backups, monitoring and those fixes are part of the work, and a support period after the final handover is written into the proposal. I run seven live systems today: their releases, servers, security and the fixes that keep them up.</p>
<figure class="post-figure dg dg-is-timeline"><ol class="dg-timeline"><li><strong>First conversation</strong><span>who uses it, what data matters, who may do what</span></li><li><strong>Written proposal</strong><span>phases, included and not included, terms, a private link</span></li><li><strong>Design</strong><span>data, permissions, connections and main screens, agreed before code</span></li><li><strong>Phases</strong><span>each one live on the client's server and tested by their team</span></li><li><strong>Handover</strong><span>code, documentation, scripts, acceptance report, training</span></li><li><strong>Support</strong><span>a period written into the proposal, then the fixes real use finds</span></li></ol></figure>
<h2 id="what-moves-the-estimate">What moves the estimate</h2>
<p>I give no price before the first conversation, because the same words describe very different systems. These move the estimate most:</p>
<ul>
<li><strong>Roles and permissions.</strong> Two roles with full access is a small system. Many roles with rules per branch is a larger one.</li>
<li><strong>Integrations.</strong> Payment gateways such as Moyasar, WhatsApp, Salla, SMS. Each has its own rules, test environment and approval steps.</li>
<li><strong>Platforms.</strong> Web only, or also Android, iOS and a Windows desktop app.</li>
<li><strong>Working offline.</strong> An app that keeps working without a connection needs a safe way to sync when it returns.</li>
<li><strong>Existing data.</strong> Importing years of spreadsheets or an old database is a task of its own.</li>
<li><strong>Reports.</strong> Each report is a query, a screen and an export.</li>
<li><strong>Operations.</strong> Backups, monitoring, and who looks after the server.</li>
</ul>
<p>Phases help here too. A first phase can be sized to the client&#39;s budget and still be useful on its own.</p>
<h2 id="the-questions-good-clients-ask">The questions good clients ask</h2>
<p>Good clients ask these before they sign. Put every answer in the proposal, and you will never have to give one in an argument.</p>
<ul>
<li>Will I get a written proposal with phases, and a list of what is not included?</li>
<li>Where will the system run, and whose name are the server and the domain in?</li>
<li>Will I test each phase on my own server before the next one starts?</li>
<li>Who owns the source code, and from when?</li>
<li>How many automated tests are there, and do they run before every release?</li>
<li>How are backups made, and when was a restore last tested?</li>
<li>What documentation will I receive at handover?</li>
<li>How are changes outside the scope priced and approved?</li>
<li>If a release goes wrong, how is it undone, and how long does that take?</li>
<li>After go-live, who fixes problems, and for how long?</li>
</ul>
<p>The final payment is easy when nothing about it is new. The client has used every phase on their own server, read the not-included list before signing, approved every change in writing, and holds a pack that lets them run the system without you. That is also why they come back.</p>
<p>Every project on my <a href="https://moghalis.com/services">services</a> page runs this way.</p>
]]></content:encoded>
    </item>
    <item>
      <title>200 animations kept my main thread 98% busy at rest. None had to go</title>
      <link>https://moghalis.com/blog/gpu-only-motion</link>
      <guid isPermaLink="true">https://moghalis.com/blog/gpu-only-motion</guid>
      <pubDate>Mon, 19 Oct 2026 08:00:00 GMT</pubDate>
      <description>One animation the GPU can't run makes the browser redo the whole page every frame. The five that did it to my site, the trace field that names them, and the fixes: 98% to 18%.</description>
      <category>performance</category>
      <category>css</category>
      <category>animation</category>
      <category>react</category>
      <content:encoded><![CDATA[<p>On my desktop the page looked finished. Runes fell behind the text, a terminal typed real commands, rings of code turned behind my portrait, phones swayed in 3D.</p>
<p>On a phone, the same page stuttered.</p>
<p>I expected a heavy image or a fat bundle. Chrome showed me something stranger: with nobody touching the screen, the main thread was busy 98% of every second.</p>
<p>So I started switching animations off to find the guilty one. Nothing changed. That dead end turned out to be the best clue of the whole hunt.</p>
<p>By the end of this article you&#39;ll know why one animation can tax all the others, how to make Chrome name the one doing it, and you&#39;ll have two scripts that measure it for you.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>One animation the compositor can&#39;t run forces a full main-thread frame on every vsync, and the whole page pays for that frame.</li>
<li>Five caused it here: a canvas loop, React state ticking, <code>background-position</code>, <code>@property</code>, and two transform animations on one element. A sixth suspect turned out innocent.</li>
<li>Measure the idle main thread under CPU throttling, A/B, with medians. Frames per second will lie to you.</li>
</ul>
</aside>
<h2 id="98-busy-and-nobody-was-touching-it">98% busy, and nobody was touching it</h2>
<p>The test was simple. Load the page in a phone profile (390×844, 3× pixel density, CPU slowed 4×), let it finish loading, touch nothing, and ask Chrome how much of each second the main thread spent running tasks.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">Phone profile, before</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">98%</span></div><div class="dg-bar-row"><span class="dg-bar-label">Phone profile, after</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.184"></span></span><span class="dg-bar-value" dir="ltr">18%</span></div><div class="dg-bar-row"><span class="dg-bar-label">Desktop, before</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.439"></span></span><span class="dg-bar-value" dir="ltr">43%</span></div><div class="dg-bar-row"><span class="dg-bar-label">Desktop, after</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.082"></span></span><span class="dg-bar-value" dir="ltr">8%</span></div></div></figure>
<p>At 98%, every tap and every scroll queues behind work the user can&#39;t see. The page looks alive and feels dead.</p>
<p>Frames per second hid all of it. Most of my motion ran on the compositor, a separate thread that kept moving layers smoothly while the main thread drowned. The FPS meter was measuring the healthy thread.</p>
<p>The number comes from one DevTools Protocol metric, <code>TaskDuration</code>: the total seconds the main thread has spent on tasks. Read it, wait three seconds, read it again, divide. The full script, with A/B runs and medians, is near the end.</p>
<p>One warning before any number: my machine was often busy with other work, so single runs lied. Every comparison in this article was A/B, interleaved, and the median of several runs.</p>
<h2 id="the-clue-switching-animations-off-changed-nothing">The clue: switching animations off changed nothing</h2>
<p>I turned animations off in groups. Removed the canvas: 98%. Removed the rings: 98%.</p>
<p>No single group mattered, so I was asking the wrong question. The cost behaved like a fixed fee, and a few animations were making the whole page pay it.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>One animation needs the main thread</strong><span>a canvas loop, a setState, a background-position</span></li><li><strong>Chrome schedules a main-thread frame</strong><span>on every vsync</span></li><li><strong>The page does its rendering work</strong><span>style, layout bookkeeping, paint, layer updates</span></li><li><strong>Taps and scrolls wait</strong><span>behind work nobody can see</span></li></ol></figure>
<p>A composited animation is a layer the GPU moves on its own. The main thread hands it over once and goes quiet. A non-composited animation needs the main thread to produce a fresh frame every time the screen refreshes, and that frame is a full one: style, layout bookkeeping, paint and layer updates, for the whole page.</p>
<p>So 200 animations, most of them already on the GPU, paid that fixed cost because a handful of others kept asking for a frame. Removing one group did nothing while another was still asking.</p>
<p>The real question became: <strong>what forces a main-thread frame?</strong> I listed six suspects and replaced every one of them. When I re-tested each alone, five were guilty and one was innocent. The first had a comment at the top of its file promising it would cost almost nothing.</p>
<h2 id="the-six-suspects-i-chased-and-what-replaced-each">The six suspects I chased, and what replaced each</h2>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>Before</span><span>After</span></div><div class="dg-compare-row"><span>A canvas redrawn from requestAnimationFrame</span><span>26 glyph columns moved by transform</span></div><div class="dg-compare-row"><span>setState for every typed character</span><span>Four nested layers stepping by transform</span></div><div class="dg-compare-row"><span>A light sliding by background-position</span><span>A rule that draws itself with scaleX and opacity</span></div><div class="dg-compare-row"><span>Opacity on an element inside an SVG</span><span>An HTML caret laid over the SVG</span></div><div class="dg-compare-row"><span>A conic border spun by @property</span><span>A rotating gradient under a mask that never moves</span></div><div class="dg-compare-row"><span>Two transform animations on one element</span><span>One per nested wrapper</span></div></div></figure>
<h3 id="1-the-canvas-that-was-built-to-cost-almost-nothing">1. The canvas that was &quot;built to cost almost nothing&quot;</h3>
<p>That&#39;s what the old file said, and it looked responsible. The rune rain was one transparent canvas at CSS pixel size, capped at 24 frames a second on phones and 30 on desktop, paused when the tab was hidden.</p>
<p>Here is its loop:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requestAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> - </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">last</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> frameMs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // too soon: skip this frame</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  last</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> t</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  draw</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>The skip is the trap. Every one of those callbacks, including the ones that return at once, is a request for a main-thread frame. The frame cap saved drawing time and nothing else.</p>
<p>The replacement draws nothing at runtime. Twenty-six columns of glyphs, each string picked once from a fixed seed (so the prerendered HTML and the browser agree), each one moved down by a CSS transform:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">rune-col</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--x</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  word-break</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> break-all</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">           /* one glyph per line: a column */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  mask-image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">transparent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> #</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">000</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 80</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* the tail fades */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -110</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rune-fall </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--dur</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   /* negative: already falling on first paint */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> rune-fall</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  to</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">vh</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">media</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">max-width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 760</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">rune-col</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">nth-child</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> none</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* half the streams on a phone */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>Each column gets its own <code>--x</code>, a duration between 11 and 24 seconds, and a negative delay. Same look. Zero script per frame.</p>
<h3 id="2-react-state-that-ticked-like-a-clock">2. React state that ticked like a clock</h3>
<p>The terminal typed by calling <code>setState</code> on a timer: every 42 to 82 ms while typing, every 18 ms while erasing. Each call meant a render, a style recalculation and a layout, because the text changed width every time.</p>
<p>Now the typing is pure CSS. A window one character wider than the command slides in one-character steps, while the text inside it slides back by the same amount. Each letter appears exactly where it stands, and only transforms move.</p>
<figure class="post-figure dg dg-is-stack"><ol class="dg-stack"><li><strong>.tw</strong><span>slides the window in, one character per step</span></li><li><strong>.tw-window</strong><span>clips, then slides back out to erase</span></li><li><strong>.tw-text-out</strong><span>slides the text back while erasing</span></li><li><strong>.tw-text</strong><span>slides the text back while typing, so letters stay put</span></li></ol></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inline-block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-in </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> both</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw-window</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> relative</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.5</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  overflow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> hidden</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-out </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--erase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--hold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> forwards</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw-text-out</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-text-out </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--erase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--hold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> forwards</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw-text</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  line-height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.5</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-text-in </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> both</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-in</span><span style="--shiki-light:#999999;--shiki-dark:#666666">       {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">-100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-out</span><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> to</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">-100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-text-in</span><span style="--shiki-light:#999999;--shiki-dark:#666666">  {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-text-out</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> to</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">caret</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">                          /* rides the window's last cell */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  right</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.15</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> currentColor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> caret-blink </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> caret-blink</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> 50%</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>React sets the timing once per command:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> type</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> CSSProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> TYPE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 55</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ERASE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 18</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> HOLD_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1700</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> TypeLine</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: { </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> timing</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--steps</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // one step per character, one for the caret</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--type</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> TYPE_MS</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--hold</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">HOLD_MS</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--erase</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ERASE_MS</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> CSSProperties</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // The key remounts the layers, so a new command restarts every animation.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> style</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">timing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw-window</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw-text-out</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw-text</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">caret</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> /></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>It needs a monospace font, since every step is <code>1ch</code>. Swap the text after <code class="code-long">text.length * (TYPE_MS + ERASE_MS) + HOLD_MS</code> plus a short gap. That&#39;s one <code>setState</code> every few seconds, down from dozens a second.</p>
<p>Look at those four layers. The first CSS version had fewer, and that is cause number six.</p>
<h3 id="3-a-light-that-slid-by-background-position">3. A light that slid by background-position</h3>
<p>Every section label had a gold rule with a light running along it: a gradient three times wider than the rule, animated with <code>background-position</code>. The compositor can&#39;t move a background inside an element, so it meant a repaint every frame, for every label.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* Before: a repaint every frame */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">eyebrow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">90</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">deg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 35</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold-fill-2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 65</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background-size</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 300</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rule-scan </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">3.4</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* After: the rule draws itself out and back */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">eyebrow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  transform-origin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> left</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> center</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rule-draw </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">3.4</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> ease-in-out</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> alternate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">html</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dir</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">rtl</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">eyebrow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  transform-origin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> right</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> center</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* grow from the side the text starts on */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> rule-draw</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> scaleX</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0.35</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0.55</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  to</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> none</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>It&#39;s a different effect, a rule that breathes in and out, and it costs nothing per frame.</p>
<h3 id="4-the-innocent-suspect-a-blinking-line-inside-an-svg">4. The innocent suspect: a blinking line inside an SVG</h3>
<p>The cursor under the M in my logo blinked with <code>opacity</code> on a path inside the SVG. I believed that repainted the SVG on every frame, so I moved the blink to an HTML element laid exactly where the SVG would draw it. The numbers come from the SVG&#39;s own geometry: a 16-unit line with 2.6-unit round caps, in a 64-unit viewBox drawn at 30px, is (16 + 2.6) × 30 ÷ 64 = 8.7px wide.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">logo-mark</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> relative</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">logo-caret</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 27.8</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8.7</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.3</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  border-radius</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--blue</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> caret-blink </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1.1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> caret-blink</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> 50%</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>Then, for this article, I re-tested every suspect on its own page. The old logo, blinking alone, caused no main-thread frames in Chrome 153 or in Edge 155, and the trace reported no failure for it. This one was innocent.</p>
<p>The HTML caret stays, since opacity on an HTML element is exactly the case the compositor is built for. The lesson is the part worth keeping: test each suspect alone, or you will credit a fix that did nothing.</p>
<h3 id="5-a-conic-border-spun-by-property">5. A conic border spun by @property</h3>
<p>The contact form had a light travelling round its border: a conic gradient whose angle was a registered custom property, animated from 0 to 360 degrees.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* Before: computed on the main thread every frame */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">property</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> --spin </span><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  syntax</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;angle></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  inherits</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> false</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  initial-value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">deg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">contact-panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> conic-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">from</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--spin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 68</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 84</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> spin-border </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p><code>@property</code> makes the angle animatable, and the compositor still can&#39;t run it. Chrome recomputes the style and repaints the gradient on every frame.</p>
<p>The same look works as a big square of conic gradient that rotates with a transform, under a 1px ring mask that never moves:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* The parent needs position: relative and isolation: isolate. */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">spin-ring</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  inset</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  z-index</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  overflow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> hidden</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  padding</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  border-radius</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inherit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  /* Keep only the 1px of padding: a ring. */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  -webkit-mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(#</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">000</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> content-box</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(#</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">000</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  -webkit-mask-composite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> xor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  mask-composite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> exclude</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  pointer-events</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> none</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">spin-ring</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> span</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 150</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  aspect-ratio</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> conic-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 68</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 84</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> spin-ring </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> spin-ring</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> to</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> rotate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">360</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">deg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>The gradient is rasterised once. Only its rotation changes, and rotation is the compositor&#39;s job.</p>
<h3 id="6-two-animations-one-element-and-the-number-64">6. Two animations, one element, and the number 64</h3>
<p>I would never have found this one by reading code. It came out of a Chrome trace, with a number attached: <strong>64</strong>.</p>
<p>In the first CSS terminal, one element carried both <code>tw-in</code> (slide in while typing) and <code>tw-out</code> (slide out while erasing), and <code>tw-out</code> only started after <code>tw-in</code> had finished moving. Chrome refused to put either of them on the compositor. Both ran on the main thread, every frame, for as long as the terminal was on screen.</p>
<p>That 64 is a bitmask from Chromium&#39;s source (<code>compositor_animations.h</code>), and bit 6 is <code class="code-long">kTargetHasIncompatibleAnimations</code>. Chromium does allow several transform-related animations on one element when each targets a different property, say <code>rotate</code> on one and <code>scale</code> on another. Mine both animated <code>transform</code>.</p>
<p>The fix is the stack you saw above: one transform animation per element, nested four deep.</p>
<p>So how do you get Chrome to hand you that number?</p>
<h2 id="make-chrome-name-the-culprit">Make Chrome name the culprit</h2>
<p>Chrome records why it couldn&#39;t composite each animation. You only have to ask. Record a trace with the <code>blink.animations</code> category and read the <code>Animation</code> events:</p>
<ul>
<li>the begin event (<code>ph: 'b'</code>) carries <code>args.data.displayName</code> (your <code>@keyframes</code> name; <code>name</code> comes back empty for CSS animations), <code>nodeName</code> and <code>nodeId</code>;</li>
<li>the instant events (<code>ph: 'n'</code>) carry <code>args.data.compositeFailed</code>, a bitmask, and <code class="code-long">args.data.unsupportedProperties</code>, the CSS properties the compositor couldn&#39;t run;</li>
<li>both share <code>id2.local</code>, which is how you pair them.</li>
</ul>
<p>Here it is as a script. Give it a URL and it prints every animation the compositor refused, with the reason in words.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// composite-audit.mjs   usage: node composite-audit.mjs http://localhost:4173/</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">playwright</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// Chromium's CompositorAnimations::FailureReason bits (compositor_animations.h)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> REASONS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">unsupported timing parameters</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">target has invalid compositing state</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 6</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">target has incompatible animations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">transform cannot be accelerated on this target</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 12</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">filter may move pixels</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 13</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">unsupported CSS property</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 17</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">no visible change</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 18</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">animates an !important property</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 19</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">SVG target has an independent transform property</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> decode</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 22</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> bit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> bit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> mask</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> REASONS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">bit </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">launch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newContext</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> viewport</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 390</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 844</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> deviceScaleFactor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> isMobile</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newPage</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">startTracing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> categories</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">devtools.timeline</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">blink.animations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">goto</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">process</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">argv</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> waitUntil</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">waitForTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">5000</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> JSON</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">parse</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">stopTracing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">())</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">close</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> idOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">id2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">local</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">id</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> started</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Animation</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ph</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">b</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> started</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">set</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">idOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> printed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Set</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Animation</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ph</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">compositeFailed</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> continue</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> displayName</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> nodeName</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> started</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">get</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">idOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {}</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> props</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unsupportedProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> line</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">nodeName</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  ${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">displayName</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  ${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">mask</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">decode</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">props </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">  [</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">props</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">]</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ''</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">printed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">has</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">line</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    printed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">line</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    console</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">line</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// Scripts never appear above. Count main-thread work in the last 3 seconds instead.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> end</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">reduce</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ts</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ts</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> end</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3e6</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">console</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">last 3 s:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  FireAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">FireAnimationFrame</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  TimerFire</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">TimerFire</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  UpdateLayoutTree</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UpdateLayoutTree</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">})</span></span></code></pre></figure>
<p>I ran it on a test page holding the &quot;before&quot; CSS from this article and the old canvas loop. This is what Chrome 153 printed:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>DIV class='contact-panel'  spin-border  8192: unsupported CSS property  [--spin]</span></span>
<span class="line"><span>SPAN class='tw-window'  tw-in  64: target has incompatible animations</span></span>
<span class="line"><span>SPAN class='tw-window'  tw-out  64: target has incompatible animations</span></span>
<span class="line"><span>SPAN class='eyebrow-rule'  rule-scan  8224: target has invalid compositing state, unsupported CSS property  [background-position-x]</span></span>
<span class="line"><span>last 3 s: { FireAnimationFrame: 180, TimerFire: 0, UpdateLayoutTree: 180 }</span></span></code></pre></figure>
<p>Read it line by line:</p>
<ul>
<li><code>[--spin]</code> and <code>[background-position-x]</code> come from <code>unsupportedProperties</code>. Note the second one: Chrome names the longhand, not the shorthand you wrote.</li>
<li>Both terminal animations fail with 64, which is why neither one ran on the GPU.</li>
<li>180 <code>FireAnimationFrame</code> callbacks in 3 seconds is the canvas loop, frame cap and all: one per vsync. The cap never reduced the callbacks.</li>
<li><code>UpdateLayoutTree</code> is Chrome&#39;s style recalculation. 180 in 3 seconds means one on every frame.</li>
</ul>
<p>The same page with the &quot;after&quot; CSS printed no failures, no animation frames, and 11 style updates in 3 seconds.</p>
<aside class="callout callout-note"><p class="callout-label">Note</p><p>The list has one blind spot: an animation on an SVG child never appears in it, failed or not. <code>UpdateLayoutTree</code> covers you. On a page at rest it should sit far below one per frame (my &quot;after&quot; page: 11 in 3 seconds, against 180). If it keeps pace with the frames, something is asking the main thread for them, whether the list names it or not.</p>
</aside>
<p>Two of the causes never show up as <code>Animation</code> events, because they are scripts: the canvas loop and the timer behind <code>setState</code>. <code>FireAnimationFrame</code> counts <code>requestAnimationFrame</code> callbacks and <code>TimerFire</code> counts timers. On a page at rest, both should be close to zero.</p>
<h2 id="two-traps-that-looked-like-good-ideas">Two traps that looked like good ideas</h2>
<p>All six were replaced and the number was down. Then one elegant selector doubled it.</p>
<p><strong><code>:has()</code> over a subtree that animates.</strong> The headline&#39;s gold sweep had to wait until the headline had finished decoding. The elegant way to say that:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">hero-headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">has</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">decode</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">not</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-done</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sheen-band</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-play-state</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> paused</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>It worked, and it doubled idle CPU on phones. With animations running inside the headline, Chrome rechecked the <code>:has()</code> on every frame.</p>
<p>The fix is boring. React adds a class once, when the decode settles, and CSS reads the class:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">hero-headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">not</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-decoded</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sheen-band</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">hero-headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">not</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-decoded</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sheen-copy</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-play-state</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> paused</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p><strong>Backdrop blur over a moving background.</strong> The desktop header had <code class="code-long">backdrop-filter: blur(18px) saturate(140%)</code> over a 78% background. Behind it the runes never stop falling, so the blur was recomputed every frame. I raised the bar to 94% and dropped the blur. At that opacity nobody can tell the difference. Phones already had it off.</p>
<h2 id="pause-what-nobody-can-see">Pause what nobody can see</h2>
<p>Animations off screen still cost something, and a long page keeps most of its animations off screen. One IntersectionObserver marks every section outside the viewport, and CSS pauses everything inside it:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-play-state</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> paused</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> !important</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">section, footer</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> default</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> OffscreenPause</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">IntersectionObserver</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> in</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> io</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">IntersectionObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> | </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">MutationObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> | </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cancelled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> false</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> each</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> fn</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Element</span><span style="--shiki-light:#999999;--shiki-dark:#666666">) => </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">void</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> instanceof</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Element</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">matches</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> fn</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">querySelectorAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fn</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> start</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">cancelled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">io</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> IntersectionObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">entries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> entries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">target</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toggleAttribute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">data-offscreen</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">isIntersecting</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)),</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> rootMargin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">160px 0px</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // start moving just before a section scrolls in</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      ))</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">querySelectorAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">observe</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">      // Route changes add and remove sections.</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> MutationObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">r</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">          r</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">addedNodes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> each</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">observe</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)))</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">          r</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">removedNodes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> each</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">unobserve</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)))</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        }),</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      )</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">observe</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> childList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> subtree</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">    // Prerendered HTML: wait until React has taken the page over, or it sees attributes it never rendered.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">requestIdleCallback</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">requestIdleCallback</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> timeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 2500</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">setTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 300</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">readyState</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">complete</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    else</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">addEventListener</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> once</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      cancelled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">removeEventListener</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">disconnect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      io</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">disconnect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [])</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>Mount it once, anywhere. Two details took me a while:</p>
<ul>
<li><strong>The 160px margin.</strong> Sections start moving before they scroll into view, so nobody ever sees one start.</li>
<li><strong>CSS pauses animations, and only animations.</strong> Your timers keep running. The terminal&#39;s timer checks <code>closest('[data-offscreen]')</code> and, if its section is paused, waits a second and checks again.</li>
</ul>
<h2 id="measure-it-yourself-the-whole-script">Measure it yourself: the whole script</h2>
<p>This is the idle measurement from the top of the article. Build the two versions you want to compare, serve them on two ports, and run one command.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// idle-busy.mjs   usage: node idle-busy.mjs http://localhost:4173/ http://localhost:4174/</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">playwright</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> RUNS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">          // per URL, interleaved: A, B, A, B...</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> SETTLE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 4000</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // let loading finish; we want the cost at rest</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> WINDOW_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3000</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">launch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">async</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> idleBusy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newContext</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    viewport</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 390</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 844</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    deviceScaleFactor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    isMobile</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    hasTouch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  })</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newPage</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newCDPSession</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">send</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Emulation.setCPUThrottlingRate</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> rate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 4</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">send</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Performance.enable</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">goto</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> waitUntil</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">waitForTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SETTLE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> taskSeconds</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> async</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">send</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Performance.getMetrics</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">metrics</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">find</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">m</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> m</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">TaskDuration</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">value</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> taskSeconds</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">waitForTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">WINDOW_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> taskSeconds</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">close</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> before</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">WINDOW_MS</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1000</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> urls</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> process</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">argv</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">slice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> runs</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Object</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">fromEntries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">urls</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> []]))</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> RUNS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">++</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> url</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> urls</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> runs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">].</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">push</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> idleBusy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">close</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> median</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [...</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">].</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">sort</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">a</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> b</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> a</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> b</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">floor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)]</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Object</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">entries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">runs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  console</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">median</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toFixed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">% busy at rest  </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">url</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">  (runs: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">x</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> x</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toFixed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>Install with <code>npm i -D playwright</code> and <code class="code-long">npx playwright install chromium</code>.</p>
<aside class="callout callout-warning"><p class="callout-label">Watch out</p><p>Make sure the browser you measure with is using the GPU. I ran mine in Edge (<code class="code-long">chromium.launch({ channel: 'msedge' })</code>), which on my machine used the real GPU even headless. Check <code>chrome://gpu</code> with the same browser and flags before you trust a number.</p>
</aside>
<aside class="callout callout-tip"><p class="callout-label">Tip</p><p>Change one thing per comparison. The runs are interleaved so a busy moment on your machine hits both versions equally, and the median throws out the run that caught a background update.</p>
</aside>
<h2 id="what-changed">What changed</h2>
<p>Same page, same animations, measured on the same phone profile:</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">98% → 18%</p><p class="stat-label">main thread busy at rest</p></div><div class="stat"><p class="stat-value" dir="ltr">1.2 → 0.1 s</p><p class="stat-label">total blocking time on load</p></div><div class="stat"><p class="stat-value" dir="ltr">67 → 17 ms</p><p class="stat-label">worst frame while scrolling</p></div></div>
<div class="table-wrap"><table>
<thead>
<tr>
<th></th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Main thread busy at rest</td>
<td>98%</td>
<td><strong>18%</strong></td>
</tr>
<tr>
<td>Total blocking time on load</td>
<td>1.2 s</td>
<td><strong>0.1 s</strong></td>
</tr>
<tr>
<td>First contentful paint</td>
<td>1.6 s</td>
<td><strong>0.7 s</strong></td>
</tr>
<tr>
<td>Worst frame while scrolling</td>
<td>67 ms</td>
<td><strong>17 ms</strong></td>
</tr>
<tr>
<td>Desktop, main thread busy at rest</td>
<td>43%</td>
<td><strong>8%</strong></td>
</tr>
</tbody></table></div>
<p>The load-time gains came from three other changes. I dropped framer-motion (42 KB gzipped) for a few CSS keyframes. The home page stopped bundling the full case-study texts, which took the main script from 416 KB to 324 KB. And the web fonts stopped holding up the app script.</p>
<p>Not one animation was removed.</p>
<h2 id="the-checklist">The checklist</h2>
<ul>
<li>Animate <code>transform</code> and <code>opacity</code> on HTML elements. Nothing else, ever, in a loop.</li>
<li>No script that runs for ever: no <code>requestAnimationFrame</code> loops, no canvas redraws, no state updates on a timer.</li>
<li>One transform animation per element. Nest a wrapper when you need two.</li>
<li>No <code>:has()</code> over a subtree that animates. Set a class once instead.</li>
<li>No <code>backdrop-filter</code> over moving content.</li>
<li>Pause what&#39;s off screen, and make your timers wait with it.</li>
<li>Run the trace audit after every new effect. Every line it prints is a main-thread frame you pay for.</li>
<li>Test each suspect alone before you credit a fix. One of my six changed nothing.</li>
<li>Measure the idle main thread, A/B, with medians. Frames per second only tell you about the healthy thread.</li>
</ul>
<p>Motion was never the expensive part. The expensive part was the one animation quietly asking the main thread for a frame, and the whole page paying for it.</p>
<p>That headline with the gold sweep broke in Arabic in a way no trace could show. It&#39;s <a href="https://moghalis.com/blog/arabic-text-effects">the other half of this story</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Per-letter text effects break Arabic. Intl.Segmenter won't save you</title>
      <link>https://moghalis.com/blog/arabic-text-effects</link>
      <guid isPermaLink="true">https://moghalis.com/blog/arabic-text-effects</guid>
      <pubDate>Mon, 19 Oct 2026 07:00:00 GMT</pubDate>
      <description>One span per letter split «لا» in my headline and doubled its gold sweep. Why grapheme clusters can't fix it, why words can, and a decode component that works in both scripts.</description>
      <category>arabic</category>
      <category>rtl</category>
      <category>typography</category>
      <category>css</category>
      <category>react</category>
      <content:encoded><![CDATA[<p>The English headline decoded beautifully. Code glyphs flickered ahead of a cursor, and each letter landed in place behind it.</p>
<p>Then I switched the page to Arabic.</p>
<p>Before the effect had even finished, my eye caught on one word: «كاملًا». The «لا» at its end, a shape every Arabic reader knows at a glance, stood there as two separate letters. A second later the gold sweep passed over the headline, and every word looked doubled.</p>
<p>One word, two bugs, and a third across the whole line. The fix every developer reaches for, <code>Intl.Segmenter</code>, cures exactly one of them.</p>
<p>And when I rebuilt the bug on a test page for this article, it got stranger. The same spans were fine in an ordinary paragraph. It took one CSS property on my headline to break them.</p>
<p>By the end you&#39;ll know which property, why the word is the smallest unit you can safely animate in Arabic, how to hand the line back so anything layered on it lines up to the pixel, and you&#39;ll have a component you can paste.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>Animate Arabic a word at a time. Letters in separate elements can lose their joins and the «لا» ligature; a whole word keeps both.</li>
<li>Split other scripts by grapheme cluster with <code>Intl.Segmenter</code>, never by code point. It keeps marks on their letters and does nothing for ligatures.</li>
<li>When the effect ends, swap the spans for plain text, so anything layered on top lines up exactly.</li>
</ul>
</aside>
<h2 id="the-version-everyone-writes-first">The version everyone writes first</h2>
<p>Split the text into characters, wrap each one in a span, keep them transparent until their turn, and draw a random glyph on top while they wait:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">char</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">dc</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">char</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">))}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-scr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> content</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> attr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-g</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* … */</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-on</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inherit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>One part of this is right, and it stayed. Every character is in the DOM from the first frame, so the line already has its final width and wraps where it will wrap. Nothing reflows while the effect runs.</p>
<p>Everything else had to change.</p>
<h2 id="what-broke-one-layer-at-a-time">What broke, one layer at a time</h2>
<h3 id="bug-1-the-marks-fell-off-their-letters">Bug 1: the marks fell off their letters</h3>
<p><code>Array.from()</code> splits a string by code point. «كاملًا» has six:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> hex</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [...</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">].</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">codePointAt</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">16</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toUpperCase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">padStart</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">4</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">0</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">hex</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">كاملًا</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// ['0643', '0627', '0645', '0644', '064B', '0627']   six code points, six spans</span></span></code></pre></figure>
<p>That <code>064B</code> is the tanween, the mark that sits on the lam. As its own code point it got its own span, separated from the letter it belongs to.</p>
<p>Don&#39;t assume your strings are free of marks. Interface Arabic drops most of them, but tanween on adverbs like «كاملًا» and «أيضًا» is standard spelling, and a shadda turns up in names like «محمّد».</p>
<p>What a reader sees as one character is a grapheme cluster, and the browser can segment those for you:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> seg</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Intl</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Segmenter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> granularity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">grapheme</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">seg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">كاملًا</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">part</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> hex</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">part</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">+</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// ['0643', '0627', '0645', '0644+064B', '0627']   five graphemes: the mark rides with its lam</span></span></code></pre></figure>
<p>Bug 1, fixed. Now look at the last two entries.</p>
<h3 id="bug-2-لا-split-in-two-and-intlsegmenter-cant-stop-it">Bug 2: «لا» split in two, and Intl.Segmenter can&#39;t stop it</h3>
<p><code>0644+064B</code> is the lam with its mark. <code>0627</code> is the alef. Two graphemes, so still two spans.</p>
<p>Lam followed by alef is the one ligature Arabic requires. Unicode keeps the finished shape only for compatibility (ﻻ, U+FEFB). Real text stores two letters, and the font draws a single shape when it shapes them together.</p>
<p>Grapheme clusters describe what a reader perceives as one character. The ligature is bigger than that: two characters the font fuses into one glyph. No grapheme split can keep it whole, because it spans two graphemes.</p>
<p>So when does the split actually show? I rebuilt the headline on a test page (IBM Plex Sans Arabic at weight 600, in Chrome 153 and Edge 155, which agreed to the pixel) and measured the line in four setups:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Text as</th>
<th>Extra CSS</th>
<th>«لا»</th>
<th>Widest line</th>
</tr>
</thead>
<tbody><tr>
<td>Plain text</td>
<td><code>text-wrap: balance</code></td>
<td>one glyph</td>
<td>415.7px</td>
</tr>
<tr>
<td>A span per word</td>
<td><code>text-wrap: balance</code></td>
<td>one glyph</td>
<td>415.7px</td>
</tr>
<tr>
<td>A span per grapheme</td>
<td>none</td>
<td>one glyph</td>
<td>within 0.4px of plain</td>
</tr>
<tr>
<td>A span per grapheme</td>
<td><code>text-wrap: balance</code></td>
<td><strong>two letters</strong></td>
<td><strong>458.9px</strong></td>
</tr>
<tr>
<td>A span per grapheme</td>
<td><code>display: inline-block</code></td>
<td><strong>two letters, no joins at all</strong></td>
<td><strong>33% wider</strong></td>
</tr>
</tbody></table></div>
<p>In an ordinary line, Chrome shapes across inline spans and the word survives. My headline had <code>text-wrap: balance</code>, and with it the same spans lost the ligature, several joins, and 43 pixels of width. The <code>inline-block</code> row is what you get from any effect that moves letters one by one, since a transform needs a box: every letter stands alone.</p>
<p>Even in the case that survives, there&#39;s a catch. With my font, the ligature is a single glyph, and Chrome paints it in the colour of the lam&#39;s span. In my test, the whole «لا» appeared the moment the lam landed, and while the lam was still transparent the alef was invisible too. Half a glyph can&#39;t be animated.</p>
<aside class="callout callout-warning"><p class="callout-label">Watch out</p><p>Test a per-letter effect with the exact CSS the text will have in production. Mine looked fine in a plain test paragraph and broke under one property on the real headline.</p>
</aside>
<h3 id="bug-3-the-gold-sweep-doubled">Bug 3: the gold sweep doubled</h3>
<p>My headline has a gold sweep on top: a second, plain-text copy of the headline, revealed by a moving band. Plain text and spans now disagreed by 43 pixels on the widest line, so they spaced and balanced differently, and the gold letters no longer sat on the white ones.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">Plain text (the gold copy)</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.906"></span></span><span class="dg-bar-value" dir="ltr">415.7px</span></div><div class="dg-bar-row"><span class="dg-bar-label">One span per word</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.906"></span></span><span class="dg-bar-value" dir="ltr">415.7px</span></div><div class="dg-bar-row"><span class="dg-bar-label">One span per grapheme</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">458.9px</span></div></div></figure>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>A span per character</span><span>A span per word, then plain text</span></div><div class="dg-compare-row"><span>«لا» drawn as two letters</span><span>«لا» stays one glyph</span></div><div class="dg-compare-row"><span>The tanween in a span of its own</span><span>Marks stay on their letters</span></div><div class="dg-compare-row"><span>The line shaped across spans</span><span>The line shaped as plain text</span></div><div class="dg-compare-row"><span>The gold sweep doubled</span><span>The sweep sits exactly on the text</span></div></div></figure>
<h2 id="the-word-is-the-smallest-safe-unit">The word is the smallest safe unit</h2>
<p>A word is the smallest piece of Arabic that carries its own shaping: its joins, its marks and its ligatures. So Arabic decodes word by word. English keeps character-by-character decoding, which reads well in Latin script and costs nothing extra.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#BDA437;--shiki-dark:#E6CC77">\p</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">{Script=Arabic}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">u</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">test</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// Spaces stay as plain text between the words.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">units</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ? </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">split</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> )</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Boolean</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> : </span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span></code></pre></figure>
<p>My site tests for the main Arabic block, U+0600 to U+06FF, which covers its strings. <code>\p{Script=Arabic}</code> is the safer choice for text you don&#39;t control: it also matches the supplement and extended blocks and the presentation forms.</p>
<p>A string with any Arabic in it goes by word, Latin words included. «مهندس برمجيات Full Stack» decodes as four words.</p>
<p>The rhythm needed two adjustments, both in the component below:</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>Latin script</span><span>Arabic script</span></div><div class="dg-compare-row"><span>One grapheme per step</span><span>One word per step</span></div><div class="dg-compare-row"><span>20 ms per step</span><span>60 ms per step: three steps per word</span></div><div class="dg-compare-row"><span>One glyph while waiting</span><span>2 to 7 glyphs, about the word's length</span></div><div class="dg-compare-row"><span>My headline: 49 characters</span><span>My headline: 8 words</span></div></div></figure>
<p>A word takes three steps, so it doesn&#39;t flash past in the time of a single letter. And while it waits, it shows a short burst of glyphs about its own length, so the line keeps its texture.</p>
<h2 id="end-on-plain-text-or-the-gold-wont-line-up">End on plain text, or the gold won&#39;t line up</h2>
<p>Per-word spans match plain text to within 0.03px on my test page. Plain text matches it exactly, with one text node in place of a span per word. So when the effect ends, the spans go away entirely.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>The last unit lands</strong><span>the requestAnimationFrame loop stops</span></li><li><strong>200 ms later</strong><span>the spans are swapped for one plain string</span></li><li><strong>onSettled fires</strong><span>React adds .is-decoded to the headline</span></li><li><strong>The gold sweep starts</strong><span>it was paused until now</span></li></ol></figure>
<p>The settled headline is now shaped exactly like plain text, ligatures and all, and the gold sweep, also plain text, lines up with it pixel for pixel.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">decode is-done</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span></code></pre></figure>
<p>The sweep waits for that moment with a class, never a <code>:has()</code>. Why a <code>:has()</code> there doubled my idle CPU is in <a href="https://moghalis.com/blog/gpu-only-motion">the performance article</a>.</p>
<p>The sweep itself is two transforms. A band 30% of the headline wide slides one way, and inside it a full-width plain-text copy slides back by the same distance on the same clock, so the gold letters stay on the white ones. Its geometry uses physical <code>left</code> on purpose. In Arabic, only the text inside should flip. The band&#39;s path stays put.</p>
<h2 id="right-to-left-for-free">Right to left, for free</h2>
<p>The decode runs right to left in Arabic without a single special case.</p>
<p>The units are in DOM order, which is logical order: the order you type and read. Index 0 is the first word a reader sees, on the right. The loop walks the indexes, so the cursor moves the way the reader&#39;s eye does.</p>
<p>The glyph overlay needs no direction rules either. It sits at <code>left: 50%</code> with <code>translate: -50% 0</code>, centred on its unit in either direction.</p>
<p>Numbers are the one place to be explicit. Digits inside Arabic text run left to right, so the figures under my headline sit in their own <code>dir=&quot;ltr&quot;</code> elements and decode through digit glyphs instead of code symbols.</p>
<h2 id="keeping-it-cheap">Keeping it cheap</h2>
<p>This effect runs a <code>requestAnimationFrame</code> loop, which my performance article treats as a warning sign. The difference is that this loop ends.</p>
<ul>
<li>One loop per text, touching only the units inside the scrambling window. It breaks out at the first unit beyond the window, and it stops when the last one lands.</li>
<li>New glyphs every 60 ms, about 16 a second: fast enough to read as noise, slow enough to see the symbols.</li>
<li>Only the units scrambling at that moment get <code>position: relative</code>, which the glyph needs. A positioned span becomes its own paint layer, and a hundred of them made every frame of the decode heavy.</li>
<li>Burst lengths for Arabic words are worked out once. Running <code>Intl.Segmenter</code> on every shuffle for every waiting word showed up in the profile.</li>
<li>With reduced motion, the text settles at once.</li>
</ul>
<p>Measured A/B on a phone profile with the CPU slowed four times, the whole effect costs about 0.35 s of main-thread work spread over the first two seconds, and nothing after that.</p>
<h2 id="the-component-ready-to-paste">The component, ready to paste</h2>
<p>Here is the component from my site, trimmed to the parts that matter. It needs TypeScript&#39;s <code>ES2022</code> lib or later for <code>Intl.Segmenter</code>, and it falls back to code points where the browser lacks it.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useMemo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useState</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> GLYPHS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">{}[]()&#x3C;>/=+-*&#x26;|!?#$%@;:01λΣ</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#BDA437;--shiki-dark:#E6CC77">\p</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">{Script=Arabic}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">u</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/** User-perceived characters, so a letter keeps its marks. */</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">):</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  typeof</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Intl</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Segmenter</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">function</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">new</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Intl</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Segmenter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> granularity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">grapheme</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">type</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Props</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  delay</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // ms before the first unit starts</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  step</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // ms per character; an Arabic word takes three steps</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  window</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // how many units ahead of the cursor are scrambling</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  onSettled</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: () => </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">void</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> default</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> DecodeText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 20</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> span</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Props</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">HTMLSpanElement</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettledRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">onSettled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    onSettledRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">current</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettled</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  })</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">test</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // Arabic by word (spaces stay plain text between words), anything else by grapheme.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> units</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useMemo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">split</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> )</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Boolean</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)),</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666">])</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settledText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> setSettledText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useState</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> |</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settledText</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> root</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">current</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">root</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">root</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">querySelectorAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">HTMLElement</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">.dc</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> settle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">      setSettledText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      onSettledRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">current</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">matchMedia</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">(prefers-reduced-motion: reduce)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">matches</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">      settle</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      return</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> noise</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> n</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> GLYPHS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">random</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> GLYPHS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">''</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">    // A word's burst is about its own length: worked out once, never per shuffle.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> burst</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">min</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">7</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">textContent</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ''</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> pace</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> start</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> performance</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">now</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> timer</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> lastShuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">now</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> now</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> start</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> shuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> now</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> lastShuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 60</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // about 16 new glyphs a second</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">shuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> lastShuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> now</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> landed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">++</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">        const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">        const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> at</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> pace</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">        if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> >=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> at</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">          if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">contains</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-on</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">remove</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-scr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-on</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          }</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">          landed</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">++</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> else</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> >=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> at</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> span</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> pace</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">          if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">shuffle</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">contains</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-scr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dataset</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">g</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> noise</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">burst</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">])</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-scr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> else</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> break</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // everything after this is still waiting</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      }</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">landed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requestAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      else</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> timer</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">setTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settle</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 200</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // then hand back plain text</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requestAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">      cancelAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">clearTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">timer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> units</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">])</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">decode is-done</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">decode</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // Settled: one plain string, shaped like any other text on the page.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // Keyed by the text, so a language switch mounts fresh, hidden units.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">units</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unit</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">dc</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>))}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* Only while scrambling: a positioned span is its own paint layer. */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-scr</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> relative</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-scr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  content</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> attr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-g</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* centred on its unit, in either direction */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  white-space</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> nowrap</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  font-family</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> ui-monospace</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> monospace</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> #</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">d6b56d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-on</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inherit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>And the headline that waits for it:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: { </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">decodedFor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> setDecodedFor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useState</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> |</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">h1</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">decodedFor</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">headline is-decoded</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">headline</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      &#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">DecodeText</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">420</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> setDecodedFor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)}</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> /></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">h1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p><code>decodedFor</code> holds the text that settled. Switch the language and the new headline hasn&#39;t settled yet, so the sweep pauses again until it has.</p>
<h2 id="the-same-rule-for-effects-that-move-text">The same rule for effects that move text</h2>
<p>A decode only changes colour, so it can live with inline spans. Effects that move letters need a box per piece, and as the table showed, an <code>inline-block</code> per letter leaves every Arabic letter standing alone.</p>
<p>Move words instead. Arabic letters never join across a space, so a box per word costs nothing in shaping. The paragraph under my headline streams in this way, one word rising a few milliseconds after the one before:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Fragment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> type</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> CSSProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/** Each word rises into place a few ms after the one before. Words stay whole, so Arabic keeps its joins. */</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> StreamText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 14</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: { </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">; </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">delay</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">; </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">step</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">split</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">word</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Fragment</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">-</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">i</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">sw</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> style</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={{</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--sw</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">delay </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> i </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> step</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> CSSProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">            {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">word</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;/</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Fragment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      ))}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sw</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inline-block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> sw-in </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">480</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ms</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> cubic-bezier</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0.22</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0.36</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> both</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--sw</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ms</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> sw-in</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0.4</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>It&#39;s one-shot CSS, opacity and transform only, with no script after the first render. Punctuation stays glued to its word, since the split is on spaces: «كاملًا،» rises as one piece with its comma.</p>
<h2 id="before-you-ship-a-text-effect-in-arabic">Before you ship a text effect in Arabic</h2>
<ul>
<li>Test a word with «لا» and a word with a mark on it. «كاملًا» has both.</li>
<li>Test a line that mixes Arabic and Latin, and one with numbers.</li>
<li>Test with the production CSS: <code>text-wrap: balance</code>, your real font and weight, your real sizes.</li>
<li>Never split by code point. Use grapheme clusters, and remember they don&#39;t protect ligatures.</li>
<li>In Arabic, the word is the smallest unit you can animate safely. If an effect needs a box per piece, give it a box per word.</li>
<li>If anything is layered on the text, end the animation on plain text.</li>
<li>Let DOM order give you direction. No special cases for right to left.</li>
<li>Look at it with a native reader&#39;s eye. &quot;All the letters are there&quot; and &quot;it reads right&quot; are different tests.</li>
</ul>
<p>Arabic is written in words, and the effect only started working when it decoded in words too.</p>
<p>If your strings carry marks you&#39;d rather not animate at all, the <a href="https://moghalis.com/tools/arabic-text">Arabic text normaliser</a> strips them before you start.</p>
]]></content:encoded>
    </item>
    <item>
      <title>One rename to deploy, 9 ms to roll back, 8 traps to dodge</title>
      <link>https://moghalis.com/blog/atomic-static-deploys</link>
      <guid isPermaLink="true">https://moghalis.com/blog/atomic-static-deploys</guid>
      <pubDate>Mon, 19 Oct 2026 06:00:00 GMT</pubDate>
      <description>My deploy kit for a static site on a small VPS: a folder per release, one atomic rename to go live, a 9 ms rollback, a read-only Caddy box, and 8 traps I found by testing it.</description>
      <category>devops</category>
      <category>caddy</category>
      <category>docker</category>
      <category>linux</category>
      <category>deployment</category>
      <content:encoded><![CDATA[<p>The archive was 9.4 MB with 413 entries, and <code>./index.html</code> sat on line 260 of its listing. My deploy script read it and printed:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>!! /tmp/moghalis-site.tar.gz has no index.html: not a site build.</span></span></code></pre></figure>
<p>The script was wrong. The guard I wrote to stop broken uploads had just stopped a good one, and it would have stopped every real build after it.</p>
<p>I caught it in a throwaway container while testing my deploy kit end to end, before writing this. It was one of eight traps I found that way. Each one is invisible until you test for it.</p>
<p>By the end you will have the whole kit: two scripts, a Caddyfile, a compose file, the reason for every line, and a checklist. It publishes a static site to a small VPS in one atomic step and brings the previous version back in 9 milliseconds.</p>
<aside class="post-summary"><p class="callout-label">In short</p><ul>
<li>Every release lives in its own folder. Caddy reads a <code>current</code> symlink, and going live is one <code>rename()</code>, with no restart and no half-copied state.</li>
<li>Test every guard with good input too. One of mine refused every real build because of <code>grep -q</code> under <code>pipefail</code>.</li>
<li>Rollback is the same rename pointed one release back. The Caddy container can read the site and nothing else.</li>
</ul>
</aside>
<h2 id="the-whole-design-is-one-directory-listing">The whole design is one directory listing</h2>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>/srv/moghalis/</span></span>
<span class="line"><span>├── stack/          compose.yaml, Caddyfile, deploy.sh, rollback.sh</span></span>
<span class="line"><span>├── releases/</span></span>
<span class="line"><span>│   ├── 20261019-142210/</span></span>
<span class="line"><span>│   ├── 20261019-171504/</span></span>
<span class="line"><span>│   └── 20261020-090031/</span></span>
<span class="line"><span>└── current -> /srv/moghalis/releases/20261020-090031</span></span></code></pre></figure>
<p>Every release is unpacked into a fresh folder named after the moment it was published. Caddy serves <code>current</code> and nothing else. Publishing points <code>current</code> at a new folder. Rolling back points it at an older one.</p>
<p>Nothing is overwritten in place, so the folder Caddy reads is never half old and half new. Notice that the link is absolute. That detail decides how the container must be mounted, further down.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow is-long"><li><strong>Pack</strong><span>tar the build with paths that start with ./</span></li><li><strong>Upload</strong><span>scp the archive to /tmp on the server</span></li><li><strong>Check</strong><span>not empty, has ./index.html, read to the end</span></li><li><strong>Unpack</strong><span>into a new releases/&lt;timestamp&gt;/ folder</span></li><li><strong>Switch</strong><span>rename current.new over current</span></li><li><strong>Verify</strong><span>ask this server for every key page</span></li></ol></figure>
<p>From my machine, after the build, it is three commands:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -czf</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> deploy/out/moghalis-site.tar.gz</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -C</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> dist</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> .</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">scp</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> deploy/out/moghalis-site.tar.gz</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis@your-vps:/tmp/</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ssh</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis@your-vps</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/stack/deploy.sh</span></span></code></pre></figure>
<p>The <code>-C dist .</code> matters: it stores every path as <code>./index.html</code>, <code>./assets/…</code>, the exact string the guard searches for.</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">5 ms</p><p class="stat-label">to switch a release</p></div><div class="stat"><p class="stat-value" dir="ltr">9 ms</p><p class="stat-label">for the whole rollback script</p></div><div class="stat"><p class="stat-value" dir="ltr">13 MB</p><p class="stat-label">Caddy's memory at idle, capped at 128 MB</p></div><div class="stat"><p class="stat-value" dir="ltr">0</p><p class="stat-label">failed reads in 60,488 during 30,000 switches</p></div></div>
<h2 id="ln--sfn-is-atomic-until-it-runs-on-alpine"><code>ln -sfn</code> is atomic until it runs on Alpine</h2>
<p>The textbook way to repoint a symlink is <code>ln -sfn new current</code>. Whether that is safe depends on which <code>ln</code> you have. I traced two:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span># GNU coreutils 9.1 (Debian 12)</span></span>
<span class="line"><span>symlinkat("r2", AT_FDCWD, "cur")        = -1 EEXIST (File exists)</span></span>
<span class="line"><span>symlinkat("r2", AT_FDCWD, "CubQNwNN")   = 0</span></span>
<span class="line"><span>renameat(AT_FDCWD, "CubQNwNN", AT_FDCWD, "cur") = 0</span></span>
<span class="line"><span></span></span>
<span class="line"><span># BusyBox (the caddy:2.10.2-alpine image)</span></span>
<span class="line"><span>unlink("cur")                           = 0</span></span>
<span class="line"><span>symlink("r2", "cur")                    = 0</span></span></code></pre></figure>
<p>GNU <code>ln</code> creates a temporary link and renames it over the old one. BusyBox, the <code>ln</code> inside Alpine-based images, deletes the old link and then creates the new one. Between those two calls, <code>current</code> does not exist.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>GNU coreutils 9.1</span><span>BusyBox on Alpine</span></div><div class="dg-compare-row"><span>creates the link under a random name</span><span>deletes current</span></div><div class="dg-compare-row"><span>renames it over current in one call</span><span>creates current again</span></div><div class="dg-compare-row"><span>current always exists</span><span>a gap between two system calls</span></div></div></figure>
<p>How often does a request land in that gap? I switched releases 10,000 times while a loop read <code>current/index.html</code> as fast as it could, three times over.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">ln -sfn on BusyBox, run 1</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">357</span></div><div class="dg-bar-row"><span class="dg-bar-label">ln -sfn on BusyBox, run 2</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.754"></span></span><span class="dg-bar-value" dir="ltr">269</span></div><div class="dg-bar-row"><span class="dg-bar-label">ln -sfn on BusyBox, run 3</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.728"></span></span><span class="dg-bar-value" dir="ltr">260</span></div><div class="dg-bar-row"><span class="dg-bar-label">ln to a temp name, then mv -T</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.000"></span></span><span class="dg-bar-value" dir="ltr">0</span></div></div></figure>
<p>The rename version made 60,488 reads across its three runs, and none failed. A real deploy switches once, so the gap is tiny. Tiny is still above zero, and the fix is one extra line.</p>
<p>So my script never trusts <code>ln</code> to be atomic. It makes the link under a temporary name and renames it itself, since <code>rename()</code> replaces a name in one step on Linux:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span></code></pre></figure>
<aside class="callout callout-warning"><p class="callout-label">Watch out</p><p>Drop the <code>-T</code> and the deploy silently does nothing. <code>mv</code> sees that <code>current</code> points at a directory and moves <code>current.new</code> into the live release. The script prints <code>==&gt; Live</code>, every check returns 200, and visitors keep getting the old site.</p>
</aside>
<p>Caddy needs no restart. The kernel resolves the link on every file open, so the next request is served from the new folder. In my test the switch took 5 ms, and the container&#39;s restart count stayed at zero.</p>
<h2 id="the-guard-that-refused-a-perfect-build">The guard that refused a perfect build</h2>
<p>Back to the opening. Before touching anything, <code>deploy.sh</code> checks its input:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">set</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -euo</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> pipefail</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -s</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE is missing or empty: upload it first (scp).</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -tzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -qx</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">./index.html</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE has no index.html: not a site build.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>It reads well. Now feed it a 413-entry archive.</p>
<p><code>grep -q</code> stops at the first match. It finds <code>./index.html</code> on line 260 and exits. <code>tar</code> is still listing, so its next write goes to a pipe with no reader. The kernel sends it <code>SIGPIPE</code>, and it dies with status 141. Under <code>pipefail</code> that one failure fails the whole pipeline, so the guard reports a missing file that is right there.</p>
<p>A tiny test archive passes, because its whole listing goes out in one write and <code>tar</code> is done before <code>grep</code> quits. My older 156-entry build fails it too. That is how a guard like this survives a quick test and blocks the real build.</p>
<p>The fix is one deleted letter. Without <code>-q</code>, <code>grep</code> reads to the end, <code>tar</code> finishes normally, and <code>pipefail</code> goes back to its real job:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -tzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -x</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">./index.html</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/dev/null</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE has no index.html: not a site build.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>I ran both versions against five archives:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Archive</th>
<th>With <code>grep -q</code></th>
<th>Without <code>-q</code></th>
</tr>
</thead>
<tbody><tr>
<td>Today&#39;s build, 413 entries</td>
<td>refused</td>
<td>accepted</td>
</tr>
<tr>
<td>The same build cut off at 4 MB, like a dropped upload</td>
<td>refused</td>
<td>refused</td>
</tr>
<tr>
<td>An empty file</td>
<td>refused</td>
<td>refused</td>
</tr>
<tr>
<td>Only the <code>assets/</code> folder</td>
<td>refused</td>
<td>refused</td>
</tr>
<tr>
<td>Packed from the wrong folder, as <code>dist/index.html</code></td>
<td>refused</td>
<td>refused</td>
</tr>
</tbody></table></div>
<p>The broken guard passed every test I would have thought to write, because every one of them fed it bad input. A guard that refuses everything looks perfect until you hand it something good.</p>
<p>The cut-off archive is the case <code>pipefail</code> exists for. <code>tar -tzf</code> reads to the end, so a truncated upload makes it fail, and the deploy stops before a single file is unpacked.</p>
<p>Once the switch is done, the script tidies up. That tidy-up line can delete the release it just put live.</p>
<h2 id="keep-five-and-sort-them-by-name">Keep five, and sort them by name</h2>
<p>After the switch, <code>deploy.sh</code> keeps the five newest releases and deletes the rest. My first version sorted them by modification time:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dt</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tail</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> xargs</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -r</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -rf</span></span></code></pre></figure>
<p>The folder you just created should be the newest. By mtime, it is not necessarily. The archive carries an entry for <code>./</code> itself, and <code>tar</code> restores that entry&#39;s timestamp onto the release folder. So a folder&#39;s mtime is the moment I ran the build on my laptop, whenever it was deployed.</p>
<p>Now redeploy an older archive, say last week&#39;s build, because you want it back and it has already left the five. Its new folder looks like the oldest on disk, <code>tail -n +6</code> picks it, and the script deletes the release it put live a moment earlier.</p>
<p>I reproduced it: five releases built on 10 to 14 October, then a build from 1 October. Afterwards <code>current</code> pointed at a folder that no longer existed, and Caddy answered every URL with a 404 and an empty body.</p>
<p>The folder name already is the deploy time, written by <code>date +%Y%m%d-%H%M%S</code>, and it sorts correctly as plain text:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tail</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> xargs</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -r</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -rf</span></span></code></pre></figure>
<p>You choose the name. <code>tar</code> chooses the mtime. Build the ordering on the one you choose, and give <code>rollback.sh</code> the same change.</p>
<h2 id="ask-the-machine-you-just-changed">Ask the machine you just changed</h2>
<p>The last thing <code>deploy.sh</code> does is request the main pages and print each status code:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">code</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">curl</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -o</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /dev/null</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -w</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">%{http_code}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --resolve</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis.com:443:127.0.0.1</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">https://moghalis.com$path</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span></code></pre></figure>
<p><code>--resolve</code> sends the request to this machine while still saying <code>moghalis.com</code> in the TLS handshake and the <code>Host</code> header. DNS, the resolver cache on my laptop and everything in between drop out of the path.</p>
<p>So a 200 proves four things at once: Caddy is up, the certificate is valid for the name (there is no <code>-k</code>), the right site block answered, and the file exists in the release that is now live.</p>
<p>On a brand-new server, before DNS points at it, Caddy has no certificate yet and every line reads <code>000</code>. After the first certificate, every line should read 200. The <code>|| true</code> keeps <code>set -e</code> from killing the report at the first failed request.</p>
<p>The check runs after the switch, because Caddy only ever serves <code>current</code>. It is a report, and I read it. If any line is red, the next command is <code>rollback.sh</code>.</p>
<aside class="callout callout-tip"><p class="callout-label">Tip</p><p>Add one path that must not exist and expect <code>404</code>. It is the cheapest possible test for the first config mistake further down.</p>
</aside>
<h2 id="rollback-is-the-same-rename-pointed-back">Rollback is the same rename, pointed back</h2>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">#!/usr/bin/env bash</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Put the previous release back live (one step, no restart).</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">set</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -euo</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> pipefail</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ROOT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/srv/moghalis</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">CURRENT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">readlink</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -f</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">PREVIOUS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sed</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">s:/$::</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -vx</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$CURRENT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> head</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! No earlier release to go back to.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">==> Rolled back to $PREVIOUS (was $CURRENT)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span></code></pre></figure>
<p>It picks the newest release that is not live and switches to it. The whole script ran in 9 ms in my test. The SSH login takes longer than the rollback.</p>
<p>Then the surprise. Run it twice and you are back where you started. After the first run, the newest release that is not live is the one you just left, so the second run rolls forward. With releases A, B and a broken C, it toggles between C and B and never reaches A.</p>
<p>The same rule bites later. Roll back from C to B, deploy a fix as D, and if D needs rolling back, the newest release that is not live is C, the broken one. So after a rollback, delete the folder you rolled back from. To go further back on purpose, name the folder:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/releases/20261019-142210</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/current.new</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/current.new</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/current</span></span></code></pre></figure>
<aside class="callout callout-note"><p class="callout-label">Note</p><p>One thing a symlink cannot fix: a tab opened before the switch that later loads a lazy JavaScript chunk. That chunk&#39;s hashed name is missing from the new release, so the request returns 404. If your app splits code by route, reload the page when a chunk fails to load.</p>
</aside>
<h2 id="caddy-in-a-box-that-can-only-read">Caddy in a box that can only read</h2>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">services</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  web</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy:2.10.2-alpine</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    restart</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> unless-stopped</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    ports</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">80:80</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443/udp</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    volumes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ./Caddyfile:/etc/caddy/Caddyfile:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis:/srv/moghalis:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_data:/data</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_config:/config</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    read_only</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    tmpfs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/tmp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    cap_drop</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ALL</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    cap_add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">NET_BIND_SERVICE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    security_opt</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">no-new-privileges:true</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    mem_limit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 128m</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    logging</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">      driver</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> json-file</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">      options</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> max-size</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 10m</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> max-file</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">3</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">volumes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  caddy_data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  caddy_config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span></code></pre></figure>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Line</th>
<th>What it takes away</th>
</tr>
</thead>
<tbody><tr>
<td><code>read_only: true</code></td>
<td>Writing to the container&#39;s own filesystem: <code>touch /etc/x</code> fails with <code>Read-only file system</code></td>
</tr>
<tr>
<td><code>:ro</code> on <code>/srv/moghalis</code></td>
<td>Changing a release, even for Caddy</td>
</tr>
<tr>
<td><code>cap_drop: [ALL]</code></td>
<td>Every Linux capability. It still runs as root inside, a root that cannot even bypass file permissions</td>
</tr>
<tr>
<td><code>cap_add: [NET_BIND_SERVICE]</code></td>
<td>Gives back one thing: listening on 80 and 443</td>
</tr>
<tr>
<td><code>no-new-privileges</code></td>
<td>Gaining privileges through a setuid binary</td>
</tr>
<tr>
<td><code>mem_limit: 128m</code></td>
<td>A runaway taking the VPS down. Caddy idled at 13 MB in my tests</td>
</tr>
<tr>
<td>log options</td>
<td>Logs filling the disk: three files of 10 MB at most</td>
</tr>
</tbody></table></div>
<p>Caddy writes only to <code>caddy_data</code>, where it keeps the certificates it obtains and renews itself. The UDP port on 443 is for HTTP/3. Before the first <code>docker compose up -d</code>, validate the config in the same image:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">docker</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> run</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PWD/Caddyfile:/etc/caddy/Caddyfile:ro</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy:2.10.2-alpine</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">  caddy</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> validate</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --config</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /etc/caddy/Caddyfile</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --adapter</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddyfile</span></span></code></pre></figure>
<p>Two details in the volumes carry the whole design. The first: it mounts all of <code>/srv/moghalis</code>. A bind mount resolves a symlink once, when the container starts, and then holds on to that directory. I tested it: after a switch, the host saw release B and the bind mount still showed A. Mount only <code>current</code> and every deploy reports success while the container serves the release it booted with.</p>
<p>The second: the folder sits at the same path inside the container. The link is absolute, so <code>/srv/moghalis/releases/…</code> has to exist in there too. Mount it at <code>/site</code> instead and <code>current</code> points at nothing.</p>
<p>The server itself is set up once by <code>bootstrap.sh</code>: a firewall open only to SSH, 80 and 443, fail2ban, automatic security updates, Docker from its own repository, and 2 GB of swap. It turns off password login only once the new user has an SSH key, so it cannot lock me out.</p>
<h2 id="two-config-lines-that-pass-every-browser-test">Two config lines that pass every browser test</h2>
<p>The Caddyfile is short. Two of its lines were wrong in my earlier config, and a browser could not show me either one.</p>
<p><strong>Every URL returned the home page.</strong> The config came from the single-page-app days:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>try_files {path} /index.html</span></span></code></pre></figure>
<p>Anything that is not a file gets the app&#39;s one HTML file. Then I started prerendering every page into <code>&lt;path&gt;/index.html</code>, 94 of them, and that line would have answered all of them with the home page.</p>
<p>I ran it against a release tree. <code>/ar</code>, <code>/work/saima</code> and a made-up <code>/no-such-page</code> all came back as the home page, each with <code>200 OK</code>. In a browser, the app would draw the right page as soon as its JavaScript ran, so clicking around would look fine. A search engine, a link preview or anyone without JavaScript would get 94 copies of the home page, and every mistyped address would become a soft 404.</p>
<p>The fix serves a page&#39;s own file, or a real 404:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>try_files {path} {path}/index.html</span></span>
<span class="line"><span>file_server</span></span>
<span class="line"><span></span></span>
<span class="line"><span>handle_errors {</span></span>
<span class="line"><span>	@notfound expression {err.status_code} == 404</span></span>
<span class="line"><span>	rewrite @notfound /404.html</span></span>
<span class="line"><span>	file_server</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p><strong>A cache rule matched pages.</strong> Case-study screenshots sit in the same folder as their page: <code>/work/saima/index.html</code> next to <code class="code-long">/work/saima/01-voice-studio.webp</code>. A week-long rule on <code>/work/*</code>, meant for the images, caught the page as well.</p>
<p>With atomic releases, that is worse than stale. Each release folder holds only its own hashed CSS and JavaScript. A browser holding a week-old page asks for files the live release no longer has, and I checked: they return 404. At best the visitor sees last week&#39;s page. At worst their cache kept the HTML and dropped the files it names, and the page loads with no styles at all.</p>
<p>The rules now go by file type. Anything that is neither a hashed asset nor an image is a page:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>@hashed path /assets/*</span></span>
<span class="line"><span>header @hashed Cache-Control "public, max-age=31536000, immutable"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>@media {</span></span>
<span class="line"><span>	not path /assets/*</span></span>
<span class="line"><span>	path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span>header @media Cache-Control "public, max-age=604800"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>@page {</span></span>
<span class="line"><span>	not path /assets/*</span></span>
<span class="line"><span>	not path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span>header @page Cache-Control "no-cache"</span></span></code></pre></figure>
<p>What the fixed config answers, probed in the same locked-down container:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Request</th>
<th>Status</th>
<th><code>Cache-Control</code></th>
</tr>
</thead>
<tbody><tr>
<td><code>/work/saima</code></td>
<td>200</td>
<td><code>no-cache</code></td>
</tr>
<tr>
<td><code class="code-long">/work/saima/01-voice-studio.webp</code></td>
<td>200</td>
<td><code>public, max-age=604800</code></td>
</tr>
<tr>
<td><code>/assets/index-….js</code></td>
<td>200</td>
<td><code class="code-long">public, max-age=31536000, immutable</code></td>
</tr>
<tr>
<td><code>/no-such-page</code></td>
<td>404, with the body of 404.html</td>
<td><code>no-cache</code></td>
</tr>
</tbody></table></div>
<p><code>no-cache</code> still caches. The browser keeps the page and asks the server whether it changed, so each visit costs one quick revalidation and a release shows up the moment it is live.</p>
<p>Both mistakes looked fine in a browser. I found them by reading status codes and headers, and this loop is the whole audit:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> p</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> in</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work/saima</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work/saima/01-voice-studio.webp</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /no-such-page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> do</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  curl</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -o</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /dev/null</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -w</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">%{http_code}  %header{cache-control}  $p\n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">https://moghalis.com$p</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">done</span></span></code></pre></figure>
<p><code>%header{}</code> needs curl 7.84 or newer. Ubuntu 24.04 ships 8.5.</p>
<h2 id="the-kit-ready-to-copy">The kit, ready to copy</h2>
<p><code>deploy.sh</code>, with every fix from this article:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">#!/usr/bin/env bash</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Publish a new release from /tmp/moghalis-site.tar.gz.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">set</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -euo</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> pipefail</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ARCHIVE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">${1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">:-/</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tmp</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">/</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">moghalis-site</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tar</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">gz}</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ROOT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/srv/moghalis</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">RELEASE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">$ROOT</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">date</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +%Y%m%d-%H%M%S</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -s</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE is missing or empty: upload it first (scp).</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -tzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -x</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">./index.html</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/dev/null</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE has no index.html: not a site build.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mkdir</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -p</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -xzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -C</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">==> Live: $RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Keep the five newest releases, by name: the name is the deploy time.</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tail</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> xargs</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -r</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -rf</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Ask this server for the routes, whatever DNS says.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> path</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> in</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /ar</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /skills</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /resume</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /blog</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /ar/blog</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /tools</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /tools/saudi-validators</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /blog/rss.xml</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work/saima</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /og-image.jpg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> do</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  code</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">curl</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -o</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /dev/null</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -w</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">%{http_code}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --resolve</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis.com:443:127.0.0.1</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">https://moghalis.com$path</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">    $code  $path</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">done</span></span></code></pre></figure>
<p>The <code>Caddyfile</code>, minus one block for my private proposal pages:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>moghalis.com {</span></span>
<span class="line"><span>	root * /srv/moghalis/current</span></span>
<span class="line"><span>	encode zstd gzip</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	header {</span></span>
<span class="line"><span>		Strict-Transport-Security "max-age=31536000; includeSubDomains"</span></span>
<span class="line"><span>		X-Content-Type-Options "nosniff"</span></span>
<span class="line"><span>		X-Frame-Options "SAMEORIGIN"</span></span>
<span class="line"><span>		Referrer-Policy "strict-origin-when-cross-origin"</span></span>
<span class="line"><span>		Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"</span></span>
<span class="line"><span>		-Server</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	@hashed path /assets/*</span></span>
<span class="line"><span>	header @hashed Cache-Control "public, max-age=31536000, immutable"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	@media {</span></span>
<span class="line"><span>		not path /assets/*</span></span>
<span class="line"><span>		path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>	header @media Cache-Control "public, max-age=604800"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	@page {</span></span>
<span class="line"><span>		not path /assets/*</span></span>
<span class="line"><span>		not path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>	header @page Cache-Control "no-cache"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	try_files {path} {path}/index.html</span></span>
<span class="line"><span>	file_server</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	handle_errors {</span></span>
<span class="line"><span>		@notfound expression {err.status_code} == 404</span></span>
<span class="line"><span>		rewrite @notfound /404.html</span></span>
<span class="line"><span>		file_server</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p>With <code>rollback.sh</code> and <code>compose.yaml</code> from above, that is all of it. The originals live in the <a href="https://github.com/moghalisprogrammer-dotcom/My-CV/tree/master/deploy/vps" target="_blank" rel="noopener noreferrer">repository</a>.</p>
<h2 id="the-checklist">The checklist</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>When</th>
<th>Check</th>
<th>Trap it stops</th>
</tr>
</thead>
<tbody><tr>
<td>Packing</td>
<td><code>tar -czf … -C dist .</code>, so paths start with <code>./</code></td>
<td>An archive from the wrong folder</td>
</tr>
<tr>
<td>First setup</td>
<td><code>caddy validate</code> in the same image says <code>Valid configuration</code></td>
<td>A typo taking the site down</td>
</tr>
<tr>
<td>First setup</td>
<td>The whole root is mounted read-only, at the same path</td>
<td>A container pinned to one release</td>
</tr>
<tr>
<td>Both scripts</td>
<td>The switch is <code>ln</code> to a temp name, then <code>mv -T</code></td>
<td>The BusyBox gap, a link moved into the live release</td>
</tr>
<tr>
<td>Both scripts</td>
<td>Releases are sorted by name</td>
<td>Deleting the release you just put live</td>
</tr>
<tr>
<td>Once</td>
<td>The guard accepts a real build and refuses empty, cut-off and wrong-folder archives</td>
<td>A guard that refuses everything</td>
</tr>
<tr>
<td>Every deploy</td>
<td>Every route returns 200 and a made-up path returns 404</td>
<td>The home page served for every URL</td>
</tr>
<tr>
<td>Every deploy</td>
<td>Pages are <code>no-cache</code>, <code>/assets/</code> a year, images a week</td>
<td>A page cached for a week</td>
</tr>
<tr>
<td>After a rollback</td>
<td>Delete the release you rolled back from</td>
<td>Rolling back into the broken one later</td>
</tr>
</tbody></table></div>
<p>That is the whole system: a folder per release, one rename, and a box that can only read. It needs one small VPS and no build step on the server, and a bad release is 9 ms from gone. Most of its lines exist to stop a mistake that a browser would never have shown me.</p>
<p>If you would rather have someone set this up and keep watch over it, that is <a href="https://moghalis.com/services/hosting-and-support">hosting and support</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
