<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://zackdesign.biz/feed.xml" rel="self" type="application/atom+xml" /><link href="https://zackdesign.biz/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-07-16T13:28:04+00:00</updated><id>https://zackdesign.biz/feed.xml</id><title type="html">Zack Design</title><subtitle>Software engineering, web development, and digital solutions by industry experts</subtitle><author><name>Isaac Rowntree</name><email>isaac@zackdesign.biz</email></author><entry><title type="html">durable-sync — offline-first sync for Cloudflare Durable Objects, no Postgres and no container</title><link href="https://zackdesign.biz/durable-sync/" rel="alternate" type="text/html" title="durable-sync — offline-first sync for Cloudflare Durable Objects, no Postgres and no container" /><published>2026-07-16T00:00:00+00:00</published><updated>2026-07-16T00:00:00+00:00</updated><id>https://zackdesign.biz/durable-sync</id><content type="html" xml:base="https://zackdesign.biz/durable-sync/"><![CDATA[<p>Zack Design has published <strong><a href="https://github.com/isaacrowntree/durable-sync">durable-sync</a></strong> — a small, zero-dependency library that gives Cloudflare Durable Objects an offline-first sync loop. An append-only op log on the server, a durable outbox on the client, and an honest status you can show users. <strong>No Postgres, no long-running container, no WebSocket.</strong> It’s the sync layer extracted from <a href="/rampset/">Rampset</a>, the offline-first gym app, cleaned up and shipped on its own. <strong>MIT licensed</strong>, on npm, and it depends on nothing.</p>

<!-- more -->

<p><strong>Docs &amp; live demo → <a href="https://isaacrowntree.com/durable-sync/">isaacrowntree.com/durable-sync</a></strong> · <strong>Source → <a href="https://github.com/isaacrowntree/durable-sync">github.com/isaacrowntree/durable-sync</a></strong> (MIT) · <strong>npm → <a href="https://www.npmjs.com/package/durable-sync"><code class="language-plaintext highlighter-rouge">durable-sync</code></a></strong></p>

<h2 id="why-this-exists">Why this exists</h2>

<p>If you want offline-first sync in 2026, the credible engines — Zero, Electric, PowerSync — all want <strong>Postgres and a long-running container</strong>. On an all-Cloudflare stack that’s a second backend to run, forever. They’re genuinely better than this at almost everything; that’s not the point. The point is the tax.</p>

<p>Meanwhile the platform already hands you the hard part for free: <strong>a Durable Object is a single-threaded ordering point, per user.</strong> That’s the exact thing everyone else reaches for Postgres to get. Give each user their own DO with <code class="language-plaintext highlighter-rouge">idFromName(userKey)</code> and write-ordering stops being a distributed-systems problem and becomes a language feature. Once you have that, the rest of “sync” is just an outbox, a cursor, and a great deal of care about what “success” means.</p>

<p>So durable-sync is small on purpose. It’s a <strong>primitive, not a database.</strong> No conflict resolution — ops are immutable facts appended to a log, and if your writes are <em>events</em> (“this workout happened”) rather than edits, you don’t have a conflict problem. HTTP, not WebSocket — a socket is useless in a basement, and gyms are Faraday cages, so sync happens when the app is open and the network exists. Bring your own IndexedDB.</p>

<h2 id="the-four-things-that-are-easy-to-get-wrong">The four things that are easy to get wrong</h2>

<p>Here’s the honest reason this is a package and not a gist. Sync that <em>looks</em> like it works and sync that <em>actually</em> works are separated by a handful of failures that are individually obvious and collectively brutal — because every one of them fails <strong>silently</strong>. Each of these shipped to production in the app this came from:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">res.ok</code> is not evidence.</strong> Behind an auth proxy like Cloudflare Access, an expired session redirects to a <em>same-origin</em> login page — which <code class="language-plaintext highlighter-rouge">fetch</code> quietly follows and reports as a <strong>200</strong>. Drain your outbox on <code class="language-plaintext highlighter-rouge">res.ok</code> and you’ve just deleted writes that never reached the server. durable-sync validates that a reply actually came from the journal before it touches the queue.</li>
  <li><strong>A cursor only means something against the log that issued it.</strong> Reset the log and every client is holding a sequence number from a log that no longer exists — usually pointing <em>past</em> the rebuilt one, so <code class="language-plaintext highlighter-rouge">seq &gt; cursor</code> matches nothing and the client <strong>silently never syncs again.</strong> Every pull carries an <code class="language-plaintext highlighter-rouge">epoch</code>; a client that sees a new one replays from zero. Apply is idempotent, so replay is cheap.</li>
  <li><strong>Pulling is not always safe.</strong> In Rampset, a remote op landing <em>mid-workout</em> rewrote the working weight that the finish logic reads back — and silently dropped a 5×5 to a 3×5. Pushing is always safe; pulling isn’t. A <code class="language-plaintext highlighter-rouge">canPull()</code> gate defers the pull while the user is in the middle of something, and eventual consistency makes waiting free.</li>
  <li><strong>The gate has to be the only door.</strong> The journal is addressed by a <em>server-side</em> identity while the ops carry whatever the client selected — so writing as the wrong one files data under someone else, permanently, in an append-only log. The engine has a <code class="language-plaintext highlighter-rouge">canWrite()</code> gate; the transport that could bypass it simply <strong>isn’t exported.</strong> There is one way in.</li>
</ul>

<p>None of these are things you’d think to test for until they’ve bitten you. That scar tissue is the actual product.</p>

<h2 id="what-you-write">What you write</h2>

<p>The server is a Durable Object you extend and export. Its methods are native DO RPC — you call them on the stub, typed, with no request-building in between:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// worker.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">SyncJournal</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">durable-sync/server</span><span class="dl">"</span><span class="p">;</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nc">Journal</span> <span class="kd">extends</span> <span class="nc">SyncJournal</span> <span class="p">{}</span>

<span class="c1">// your route — forward exactly the methods a client should reach</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">POST</span><span class="p">(</span><span class="nx">req</span><span class="p">:</span> <span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">ops</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">req</span><span class="p">.</span><span class="nf">json</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">journal</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">JOURNAL</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="nx">env</span><span class="p">.</span><span class="nx">JOURNAL</span><span class="p">.</span><span class="nf">idFromName</span><span class="p">(</span><span class="nx">userKey</span><span class="p">));</span>
  <span class="k">return</span> <span class="nx">Response</span><span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="k">await</span> <span class="nx">journal</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="nx">ops</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note what you <em>don’t</em> forward: <code class="language-plaintext highlighter-rouge">reset()</code>. The DO has it, but a client reaches only the methods you wire to a route — that’s the whole access model, no router second-guessing you. The client is local-first: commit to IndexedDB, queue the op, let the network catch up.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">createSync</span><span class="p">,</span> <span class="nx">localStorageCursor</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">durable-sync/client</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">sync</span> <span class="o">=</span> <span class="nf">createSync</span><span class="p">({</span>
  <span class="na">endpoint</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/api/sync</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">outbox</span><span class="p">:</span> <span class="nf">dexieOutbox</span><span class="p">(</span><span class="nx">db</span><span class="p">.</span><span class="nx">outbox</span><span class="p">),</span>          <span class="c1">// durable — it may hold the only copy</span>
  <span class="na">cursor</span><span class="p">:</span> <span class="nf">localStorageCursor</span><span class="p">(</span><span class="dl">"</span><span class="s2">myapp.cursor</span><span class="dl">"</span><span class="p">),</span>
  <span class="k">async</span> <span class="nf">apply</span><span class="p">(</span><span class="nx">op</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* idempotent: an op can arrive twice */</span> <span class="p">},</span>
  <span class="na">canPull</span><span class="p">:</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="p">(</span><span class="k">await</span> <span class="nf">somethingInProgress</span><span class="p">()),</span>
<span class="p">});</span>
</code></pre></div></div>

<p>There’s a <strong><a href="https://github.com/isaacrowntree/durable-sync/tree/main/examples/notes">runnable example</a></strong> in the repo — a Worker plus a browser client with a real IndexedDB outbox — and a <strong><a href="https://isaacrowntree.com/durable-sync/">live demo on the docs site</a></strong> where you can cut the network, watch writes pile up in the outbox with no sequence number, then reconnect and watch them drain. That widget <em>is</em> the library.</p>

<h2 id="when-to-use-something-else">When to use something else</h2>

<p>Being clear so you don’t adopt it and find out: no conflict resolution, no pagination (a pull returns everything after the cursor in one response — fine for thousands of ops, not millions), no live push, no auth, and nothing runs while the app is closed because Safari has no Background Sync. If you need those, you want a real sync engine and the Postgres that comes with it — there’s an <a href="https://isaacrowntree.com/durable-sync/vs.html">honest comparison</a> against Zero, Electric, PowerSync and Cloudflare’s own partysync on the docs site. If you need two people editing the same record to <em>merge</em>, you want a CRDT — Yjs or Automerge.</p>

<p>The slice where durable-sync is the right answer is narrow: you’re all-Cloudflare, your writes are events, and the app has to keep working on a train. Inside that slice, it’s a few kilobytes and no second backend.</p>

<h2 id="the-lesson">The lesson</h2>

<p>Extracting a library from a working app is a good forcing function: it makes you separate the parts that are genuinely reusable from the parts that were only ever about your problem. What surprised me is how little of the value was in the code. The op log is a hundred lines. The outbox is an interface. Anyone could write those in an afternoon — and then spend the next year discovering, one silent data-loss bug at a time, the four things above.</p>

<p>That year is what’s in the package. The code is small; the scar tissue is the product.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="cloudflare" /><category term="durable-objects" /><category term="typescript" /><category term="offline-first" /><category term="local-first" /><category term="sync" /><category term="pwa" /><category term="event-sourcing" /><category term="open-source" /><category term="claude-code" /><summary type="html"><![CDATA[A small, zero-dependency TypeScript library that gives Cloudflare Durable Objects an offline-first sync loop: an append-only op log on the server, a durable outbox on the client. No Postgres, no long-running container, no WebSocket. Extracted from Rampset, MIT licensed, on npm.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/durable-sync.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/durable-sync.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Sovereign — an autonomous IBKR fund that trades through bezant, with guardrails because it’s real money</title><link href="https://zackdesign.biz/sovereign/" rel="alternate" type="text/html" title="Sovereign — an autonomous IBKR fund that trades through bezant, with guardrails because it’s real money" /><published>2026-07-08T00:00:00+00:00</published><updated>2026-07-08T00:00:00+00:00</updated><id>https://zackdesign.biz/sovereign</id><content type="html" xml:base="https://zackdesign.biz/sovereign/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/sovereign-ibkr-fund"><strong>Sovereign</strong></a> — an open-source, multi-agent portfolio fund for Interactive Brokers. It’s the companion to <a href="https://github.com/isaacrowntree/bezant">bezant</a>: bezant mints the access, Sovereign spends it. Nine deterministic TypeScript agents hold a model portfolio, detect drift, size trades with real risk controls, and execute through bezant — standalone, or under whatever scheduler you already run.</p>

<!-- more -->

<p><strong>Source → <a href="https://github.com/isaacrowntree/sovereign-ibkr-fund">github.com/isaacrowntree/sovereign-ibkr-fund</a></strong> (Apache-2.0 OR MIT) · Built on <strong><a href="https://github.com/isaacrowntree/bezant">bezant</a></strong></p>

<h2 id="why-it-exists">Why it exists</h2>

<p>I had a portfolio problem that a lot of people quietly have: one winner had run so hard it became <strong>40%+ of the book</strong>. Great on the way up, a single point of failure on the way down. Fixing that by hand — trimming the concentration, funding a diversified target, doing it without fat-fingering a real order — is exactly the kind of repetitive, high-stakes, easy-to-get-wrong work you should not be doing manually at 11pm.</p>

<p>So the fund isn’t a get-rich bot. It’s a <strong>discipline engine</strong>: hold a target allocation, notice when reality drifts from it, and make the smallest correct trades to close the gap — with enough guardrails that a bad market-data tick or a logic bug can’t do real damage.</p>

<p>bezant already gave every language typed access to IBKR’s Client Portal API. Sovereign is what happens when you build an actual fund on that foundation and take the “it’s real money” part seriously.</p>

<h2 id="nine-agents-no-llm-calls">Nine agents, no LLM calls</h2>

<p>The agents are plain TypeScript <code class="language-plaintext highlighter-rouge">--once</code> processes — <strong>deterministic, no model calls in the loop.</strong> That’s deliberate: I want a trade decision to be a pure function of the inputs, reproducible and auditable, not a sample from a distribution.</p>

<table>
  <thead>
    <tr>
      <th>Agent</th>
      <th>Job</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Managing Partner</strong></td>
      <td>Orchestrates the fund, snapshots NAV and positions</td>
    </tr>
    <tr>
      <td><strong>Portfolio Strategist</strong></td>
      <td>HRP / Black-Litterman weights, drift detection, sizes rebalance orders</td>
    </tr>
    <tr>
      <td><strong>Quant Analyst</strong></td>
      <td>Regime detection, factor regression</td>
    </tr>
    <tr>
      <td><strong>Risk Manager</strong></td>
      <td>VaR / CVaR, drawdown control, volatility targeting</td>
    </tr>
    <tr>
      <td><strong>Execution Bot</strong></td>
      <td>Places orders through bezant — window-gated, capped, reconciled</td>
    </tr>
    <tr>
      <td><strong>Tax Optimizer</strong></td>
      <td>FIFO lots, tax-loss harvesting, wash-sale tracking</td>
    </tr>
    <tr>
      <td><strong>Hedger</strong></td>
      <td>Options overlay (covered calls, protective puts)</td>
    </tr>
    <tr>
      <td><strong>Research Scout</strong></td>
      <td>Price monitoring and alerts</td>
    </tr>
    <tr>
      <td><strong>Observer</strong></td>
      <td>Ingests the WebSocket fill/event stream</td>
    </tr>
  </tbody>
</table>

<p>Under the hood there’s a real quant toolbox: Hierarchical Risk Parity, Black-Litterman, risk-parity, Ledoit-Wolf shrinkage covariance, a regime overlay, and vol targeting — all backtestable.</p>

<h2 id="the-guardrails-are-the-point">The guardrails are the point</h2>

<p>Anyone can write a loop that places market orders. The interesting engineering is everything that stops it from doing something stupid with real money:</p>

<ul>
  <li><strong>Validation-first execution.</strong> Before it will batch a rebalance, the executor proves a live fill on the single smallest order and confirms it against IBKR’s own execution records. No confirmed fill, no batch.</li>
  <li><strong>Executions are authoritative.</strong> Fills are reconciled against IBKR’s execution log, and the trade ledger is <strong>idempotent</strong> — the same fill can never be recorded twice, even if a confirmation arrives by two paths.</li>
  <li><strong>Hard caps.</strong> Absolute backstops on per-order notional, per-order % of NAV, and per-run notional — independent of the sizing math. A garbled input can’t size a monster order.</li>
  <li><strong>Data-sanity gates.</strong> If NAV or a price tick looks impossible (zeroed, or a 100× move with no cash flow), the strategist refuses to generate orders that cycle rather than trade against garbage.</li>
  <li><strong>Drawdown control.</strong> De-risk and hard-stop thresholds that pull exposure down when the book is bleeding.</li>
  <li><strong>State lives outside the checkout.</strong> Positions and ledger sit in <code class="language-plaintext highlighter-rouge">STATE_DIR</code>, never entangled with the code — so a deploy can never clobber your holdings.</li>
</ul>

<p>Most of these exist because the honest way to build this is to assume your own code will misbehave and make sure the blast radius is bounded when it does.</p>

<h2 id="standalone-or-under-whatever-you-run">Standalone, or under whatever you run</h2>

<p>Every agent is just <code class="language-plaintext highlighter-rouge">node dist/agents/&lt;name&gt;.js --once</code>. That one contract means Sovereign doesn’t care how it’s scheduled:</p>

<ul>
  <li><strong>Built-in scheduler</strong> — <code class="language-plaintext highlighter-rouge">npm start</code> runs the whole fund on a cadence, zero dependencies.</li>
  <li><strong>cron / systemd</strong> — point timers at the <code class="language-plaintext highlighter-rouge">--once</code> scripts; examples in <code class="language-plaintext highlighter-rouge">deploy/</code>.</li>
  <li><strong>Any orchestrator</strong> — set <code class="language-plaintext highlighter-rouge">ENABLE_SCHEDULER=false</code> and let your platform drive the same scripts.</li>
</ul>

<p>The core never imports the scheduler, so it genuinely runs both ways.</p>

<h2 id="your-book-stays-yours">Your book stays yours</h2>

<p>The public repo ships a <strong>generic sample portfolio</strong> (a diversified ETF template) and runs against <strong>paper</strong> out of the box. Your real allocation goes in a gitignored <code class="language-plaintext highlighter-rouge">src/portfolios/local.ts</code> that takes precedence automatically and never leaves your machine. Everything else — caps, thresholds, cadences, optimizer choice — is environment-driven. Nothing about <em>your</em> positions lives in the source.</p>

<h2 id="backtesting">Backtesting</h2>

<p>There’s a full backtest engine (HRP, risk-parity, Black-Litterman, regime overlay, vol targeting). The historical dataset is gitignored — you generate it yourself from Yahoo Finance with <code class="language-plaintext highlighter-rouge">npm run fetch-data</code>, and the backtest suites skip cleanly until it exists, so a fresh clone is green on the first <code class="language-plaintext highlighter-rouge">npm test</code>.</p>

<h2 id="status-and-licensing">Status and licensing</h2>

<ul>
  <li><strong>v0.1</strong> — runs end-to-end against IBKR paper accounts; the API and agent set will evolve.</li>
  <li><strong>Dual-licensed Apache-2.0 OR MIT.</strong></li>
  <li><strong>Requires a running <a href="https://github.com/isaacrowntree/bezant">bezant</a> gateway</strong> — that’s how it talks to IBKR.</li>
  <li><strong>Not affiliated with Interactive Brokers.</strong> This is not financial advice. It places real trades — <strong>start on paper</strong>, and understand every guardrail before you flip <code class="language-plaintext highlighter-rouge">TRADING_MODE=live</code>.</li>
</ul>

<p>If bezant was about making IBKR <em>programmable</em>, Sovereign is about making a portfolio <em>governable</em> — trading on rules you can read, with brakes you can trust. If you’re running money through IBKR and you’d rather it followed a written policy than your 11pm instincts, clone it and point it at your own bezant. Contributions welcome — especially on the optimizers and the risk engine.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="ibkr" /><category term="trading" /><category term="typescript" /><category term="portfolio" /><category term="quant" /><category term="risk" /><category term="backtesting" /><category term="interactive-brokers" /><category term="open-source" /><category term="claude-code" /><category term="bezant" /><summary type="html"><![CDATA[An open-source, multi-agent portfolio fund for Interactive Brokers, built on top of bezant. Deterministic TypeScript agents handle allocation, risk, tax, and execution — with validation-first trading, hard caps, and data-sanity gates. Runs standalone or under any scheduler. Dual-licensed Apache/MIT.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/sovereign.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/sovereign.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rampset — I got fired as a customer, so I built the gym app myself and open-sourced it</title><link href="https://zackdesign.biz/rampset/" rel="alternate" type="text/html" title="Rampset — I got fired as a customer, so I built the gym app myself and open-sourced it" /><published>2026-07-06T00:00:00+00:00</published><updated>2026-07-06T00:00:00+00:00</updated><id>https://zackdesign.biz/rampset</id><content type="html" xml:base="https://zackdesign.biz/rampset/"><![CDATA[<p>Zack Design has published <strong><a href="https://github.com/isaacrowntree/rampset">Rampset</a></strong> — an open-source, offline-first strength-training PWA that replaces the app I logged eleven years of workouts in. It runs three different training styles on one engine, works in a gym with zero signal, optionally syncs between devices through a per-user Durable Object, and snapshots every workout to R2. It’s <strong>MIT licensed</strong> and built to self-host: your lifters, your data, your Cloudflare account, up in about ten minutes.</p>

<!-- more -->

<p><strong>Site &amp; guide → <a href="https://isaacrowntree.github.io/rampset">isaacrowntree.github.io/rampset</a></strong> · <strong>Source → <a href="https://github.com/isaacrowntree/rampset">github.com/isaacrowntree/rampset</a></strong> (MIT)</p>

<h2 id="why-this-exists">Why this exists</h2>

<p>I was a StrongLifts user from <strong>2015 to 2026</strong> — 1,221 logged workouts, a grandfathered subscription I’d held since 2018, and honestly no complaints for over a decade. It did the one thing a 5×5 app has to do: tell me what to lift today and remember what I lifted yesterday.</p>

<p>Then a sign-in bug locked me out of my own subscription. I did the support dance — reinstalled the app, removed and re-added every Google account on the phone, followed every step of every linked help article, sent screenshots. After a morning of back-and-forth, support resolved it their way: <strong>they cancelled my subscription.</strong> I hadn’t asked for that. The legacy plan I’d been quietly grandfathered on for years ceased to exist the moment it was cancelled, and the only path back was the shiny new trial at roughly six times my old price.</p>

<p>Well — screw that. I write software for a living, my workout history exports to CSV, and it’s 2026: with Claude Code in the loop, “I’ll just build it myself” is no longer a threat you mutter into an email draft and delete. It’s a Saturday.</p>

<h2 id="three-ways-to-train">Three ways to train</h2>

<p>The interesting design constraint wasn’t me — it was that my wife trains too, and her program is nothing like mine. That forced a better architecture than a clone would have had, and it’s why Rampset ships with three shapes on one engine:</p>

<ul>
  <li><strong>Program mode</strong> — classic StrongLifts-style <strong>5×5</strong>, A/B, with variants from Lite to Ultra Max. The app prescribes: tap a plate-shaped circle to log a set, warm-ups ramp with per-side plate math, +2.5 kg linear progression, graduated deloads after time off, and the full stall protocol (5×5 → 3×5 → 1×5).</li>
  <li><strong>Madcow mode</strong> — weekly ramped 5×5: heavy / light / intensity days, back-off sets, and a Friday PR set that sets next week’s top weight.</li>
  <li><strong>Routine mode</strong> — a Strong-style layout: exercise cards with set rows (previous · kg · reps · ✓), everything prefilled from the last session, per-exercise rest timers, timed and bodyweight sets. You prescribe; the app remembers.</li>
</ul>

<p>Same schema, same logging engine, same charts — the mode is just a flag on the program. Both of our full histories came in through CSV importers for the two incumbent apps’ export formats, auto-detected and idempotent, verified against the real files. And export gives it all back whenever you want — no lock-in is the whole point.</p>

<p><img src="/images/blog/rampset-app.png" alt="Rampset home screen: dark OLED UI showing the next 5×5 workouts with working weights and a Start workout button" style="max-width: 320px; width: 100%; display: block; margin: 1.5rem auto; border-radius: 24px;" /></p>

<h2 id="the-parts-im-fond-of">The parts I’m fond of</h2>

<ul>
  <li><strong>Offline-first for real.</strong> Gyms are Faraday cages. IndexedDB is the source of truth on-device; the service worker keeps the shell alive; a screen wake-lock holds the display on mid-workout; and a rest timer keeps counting on a wall-clock deadline even when the phone locks, then rings a WebAudio bell it synthesises itself. No signal just means sync happens later.</li>
  <li><strong>Sync without a database server.</strong> Each person gets their own SQLite-backed Durable Object — a single-threaded, per-user journal that makes write ordering a non-problem instead of a distributed-systems problem. Finished workouts push as idempotent ops; fresh devices pull. R2 holds dated snapshots underneath as the disaster layer.</li>
  <li><strong>No auth code, no personal data in the repo.</strong> Identity is handled entirely by <strong>Cloudflare Access</strong> (a free zero-trust login) — the app itself contains zero auth code. Lifters are defined by an env var that’s gitignored; the committed code carries only generic program templates. Nothing about you or your training lives in the source.</li>
  <li><strong>TDD the whole way.</strong> 240 tests: the progression engine, both CSV dialects, the sync journal, backup round-trips, even “a rest timer survives navigating away mid-countdown.” The engines are pure functions in <code class="language-plaintext highlighter-rouge">src/lib/</code>, each with a failing-test-first workflow waiting — because editing them is the point.</li>
  <li><strong>It feels like an app.</strong> OLED-black UI, floating dock, haptic ticks on set logging, sheets that close with the Android back gesture, splash screens, home-screen shortcuts. The bar for “doesn’t feel like a website” is a hundred small details, and a multi-agent review fleet is very good at finding all hundred.</li>
</ul>

<h2 id="run-your-own">Run your own</h2>

<p>Rampset is built to be yours. Clone it, point it at your lifters, and either use it locally or deploy your own copy to Cloudflare:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/isaacrowntree/rampset
<span class="nb">cd </span>rampset
npm <span class="nb">install
cp</span> .env.example .env.local     <span class="c"># your lifters, units, starting weights</span>
npm run dev                    <span class="c"># → http://localhost:3000</span>
npm <span class="nb">test</span>                       <span class="c"># the engines, importers, store, components</span>
</code></pre></div></div>

<p>To put it online, <code class="language-plaintext highlighter-rouge">npm run deploy</code> ships it to Cloudflare Workers via the OpenNext adapter; then you add a <strong>Cloudflare Access</strong> application in front of your Worker and allow your lifters’ emails. Workers and Access are free for a household. The one paid gate is <strong>R2</strong> for cloud backups — the data sits inside R2’s 10 GB free tier, but Cloudflare wants a card on file to enable it. Don’t want to add one? Drop the <code class="language-plaintext highlighter-rouge">BACKUPS</code> binding: the app is fully offline-first on local IndexedDB, and <strong>Settings → Export</strong> hands you a complete CSV backup whenever you want.</p>

<h2 id="the-lesson">The lesson</h2>

<p>There’s a quiet lesson in this for anyone running a subscription product: your most loyal decade-long customers aren’t locked in by your data-export button being hard to find anymore. They’re one bad support interaction away from an export file, a coding agent, and a very productive Saturday afternoon.</p>

<p>And here’s the part that should really keep you up at night. This time they don’t even have to build it. The export importers, the two proven programs, the offline sync, the plate math — it’s all sitting in a public repo under a licence that says <em>make it yours</em>. Take it to the gym. Take it somewhere no subscription can reach you.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="product" /><category term="nextjs" /><category term="react" /><category term="pwa" /><category term="cloudflare" /><category term="durable-objects" /><category term="typescript" /><category term="tdd" /><category term="fitness" /><category term="open-source" /><category term="claude-code" /><summary type="html"><![CDATA[An open-source, offline-first barbell training PWA — guided 5×5 and Madcow programs, freeform routines, per-user Durable Object sync, R2 snapshots — born the day my eleven-year StrongLifts subscription was cancelled without me asking. MIT licensed, self-host it in about ten minutes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/rampset.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/rampset.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Taking over Sequin: adopting an orphaned CDC engine, fixing the Dragonfly crash, and putting it behind Cloudflare Access</title><link href="https://zackdesign.biz/taking-over-sequin/" rel="alternate" type="text/html" title="Taking over Sequin: adopting an orphaned CDC engine, fixing the Dragonfly crash, and putting it behind Cloudflare Access" /><published>2026-07-01T00:00:00+00:00</published><updated>2026-07-01T00:00:00+00:00</updated><id>https://zackdesign.biz/taking-over-sequin</id><content type="html" xml:base="https://zackdesign.biz/taking-over-sequin/"><![CDATA[<p><a href="https://github.com/sequinstream/sequin">Sequin</a> is an open-source Postgres change-data-capture engine written in Elixir. It tails a Postgres logical replication slot and streams every insert, update, and delete out to sinks — Typesense, webhooks, Kafka, SQS — with Elixir functions in the middle to transform or filter each row. I use it to keep the search index and a few downstream services for <a href="https://campermate.com">CamperMate</a> — the free-camping and campground app across Australia and New Zealand, <a href="https://apps.apple.com/app/campermate/id578975305">iOS</a> and <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a>, 1M+ downloads — in lockstep with the source-of-truth Postgres database.</p>

<p>This is the story of how a tool I adopted became a tool I help maintain: how I found it, ran it in production, watched it fall over for a reason that wasn’t really its fault, went looking for help from a company that no longer existed, and ended up forking it, fixing it, and extending it.</p>

<!-- more -->

<h2 id="why-i-reached-for-sequin">Why I reached for Sequin</h2>

<p>The problem Sequin solves is the boring, load-bearing kind. The POI, review, and translation data lives in Postgres (on <a href="https://neon.tech">Neon</a>). Search runs on <a href="https://typesense.org">Typesense</a>. Several other services — a translation workflow, a delta cache for the mobile app — need to know the instant a row changes. The naive version of this is a spray of application-level hooks and cron jobs that drift out of sync the moment anything fails silently.</p>

<p>CDC inverts that. Postgres already writes every change to its write-ahead log; Sequin reads the log and fans each change out reliably, with retries and backfills, so the index and the database can’t disagree for long. The whole thing is a dozen sinks driven by a single declarative <a href="https://github.com/sequinstream/sequin"><code class="language-plaintext highlighter-rouge">sequin.yaml</code></a>, version-controlled and applied from CI:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   Postgres (Neon)  →  Sequin  →  Typesense collections (POIs, reviews, translations)
   logical slot        transforms →  Webhooks (translation pipeline, delta sync)
</code></pre></div></div>

<p>The transforms are the nice part. A small Elixir function denormalises a POI’s feature list into boolean columns, builds language-suffixed fields (<code class="language-plaintext highlighter-rouge">description_en</code>, <code class="language-plaintext highlighter-rouge">description_mi</code>) from a tall translations table, and filters out non-public rows before they ever reach the public index. It’s the right amount of logic in the right place, and for a good while it just worked.</p>

<h2 id="the-night-dragonfly-took-sequin-with-it">The night Dragonfly took Sequin with it</h2>

<p>Then came outages that made no sense. Sequin would be humming along, and then — with no deploy, no traffic spike, no obvious cause — every consumer would stop at once. The sinks would go cold, the index would fall behind, and the only fix was a restart.</p>

<p>The correlation, once I traced it, was infuriating: it happened whenever <strong>Dragonfly</strong> — the Redis-compatible store Sequin uses for coordination, running as a managed service on <a href="https://railway.app">Railway</a> — redeployed itself. A routine maintenance update to a <em>dependency</em> was taking down the whole engine.</p>

<p>The mechanism is a textbook Erlang supervision-tree footgun. Sequin uses a Redis-backed mutex to elect a single leader across nodes — a <code class="language-plaintext highlighter-rouge">MutexOwner</code> GenServer that holds a lock and refreshes it before it expires. When Dragonfly restarted, the connection blipped, the mutex refresh failed, and <code class="language-plaintext highlighter-rouge">MutexOwner</code> did the most literal possible thing: it crashed. And because it sits under a supervisor with a <code class="language-plaintext highlighter-rouge">:one_for_all</code> restart strategy, its death took <strong>every sibling down with it</strong> — the entire runtime supervisor, all consumers, all sinks. A two-second Redis blip became a total outage that only a human restart would clear.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MutexedSupervisor (:one_for_all)
  └── MutexOwner  ✗  Redis blips → mutex refresh fails → crash
        ⇒ :one_for_all fires ⇒ every consumer &amp; sink is torn down with it
</code></pre></div></div>

<p>That’s not really Sequin doing something wrong so much as an assumption — “Redis is always there” — that doesn’t hold on a platform where your Redis can redeploy under you at any moment.</p>

<h2 id="upstream-had-gone-dark">Upstream had gone dark</h2>

<p>So I did the normal thing: went to fix it upstream, or at least to ask. And found the lights off. The project had moved into <strong>maintenance mode</strong>, the company behind Sequin had wound down, and there was no one on the other end of the issue tracker to merge a patch or ship a release. A tool at the centre of a data pipeline had, effectively, been orphaned.</p>

<p>This is the quiet risk of building on someone else’s open source: the licence guarantees you the code, but nothing guarantees you a maintainer. When the maintainer disappears, you have exactly two choices — rip the dependency out, or adopt it.</p>

<h2 id="so-i-adopted-it">So I adopted it</h2>

<p>Ripping out CDC and rebuilding the sink pipeline from scratch would have been weeks of work to end up back where I started. Adoption was the better trade. The project got forked into <a href="https://github.com/triptechtravel/sequin"><code class="language-plaintext highlighter-rouge">github.com/triptechtravel/sequin</code></a>, with its own build and release pipeline, and from then on it was treated as what it now was: code I own.</p>

<p>That meant the unglamorous infrastructure of ownership. A GitHub Actions workflow that builds a patched image and pushes it to GHCR. Deployment to Railway. Fixing the parts of the build that assumed a corporate CI — a missing <code class="language-plaintext highlighter-rouge">cmake</code> for a Kafka NIF, a Sentry DSN that was baked in at build time and now had to be optional. None of it is exciting. All of it is the price of being the maintainer instead of a user.</p>

<h2 id="fixing-the-crash-properly">Fixing the crash properly</h2>

<p>With the fork in hand, the Dragonfly bug got fixed at the root. <code class="language-plaintext highlighter-rouge">MutexOwner</code> no longer treats a Redis error as fatal. While it holds the mutex and Redis becomes unreachable, it now <strong>retries indefinitely with exponential backoff</strong> (capped at an hour) instead of crashing — Redis going down should degrade Sequin gracefully, never take it out. When Redis comes back, it re-acquires the lock and resumes as if nothing happened. The invalid GenStateMachine stop value that caused the original crash got corrected too, and the LiveView metrics pages were hardened so a Redis blip renders an empty chart instead of a <code class="language-plaintext highlighter-rouge">MatchError</code>.</p>

<p>The part I’m most pleased with is the test. It’s easy to write a unit test that mocks a Redis error; it’s much more convincing to simulate the actual failure. The integration test uses <code class="language-plaintext highlighter-rouge">iptables</code> to <strong>REJECT</strong> traffic to Redis mid-run — a real network partition, the same thing a Dragonfly redeploy looks like from the process’s point of view — and asserts that the <code class="language-plaintext highlighter-rouge">MutexOwner</code> survives the outage and recovers when the rule is dropped. That’s the difference between “handles a mocked error” and “survives the thing that was actually paging me.”</p>

<p>The outages stopped.</p>

<h2 id="then-it-went-behind-cloudflare-access">Then it went behind Cloudflare Access</h2>

<p>Once you own a fork, you stop just patching it and start shaping it. The most recent addition: real SSO on the admin console.</p>

<p>Out of the box, self-hosted Sequin authenticates users with an email-and-password login sitting in its own Postgres table. For an internal tool that a whole team touches, that’s the wrong model — there’s already Google identity and <a href="https://www.cloudflare.com/zero-trust/">Cloudflare Zero Trust</a> in front of everything else. So the Sequin console went behind <strong>Cloudflare Access</strong>, and Sequin learned to trust it.</p>

<p>The mechanics, mirroring the same trusted-header pattern I’ve used for a <a href="https://payloadcms.com">Payload CMS</a>:</p>

<ul>
  <li><strong>Cloudflare Access</strong> gates the console with a Google-SSO policy. On every request it forwards, it injects a signed JWT in the <code class="language-plaintext highlighter-rouge">Cf-Access-Jwt-Assertion</code> header.</li>
  <li>A new Elixir plug <strong>verifies that JWT</strong> — fetching the Access application’s public keys (JWKS), checking the signature, issuer, audience, and expiry — and then transparently signs the user in. First time through, it <strong>provisions the user just-in-time</strong> from the verified email, adopting any existing account so nobody lands in an empty instance. You never see Sequin’s own login screen.</li>
  <li>The tricky part is machine traffic. The search config is applied from CI, which authenticates with a token rather than a browser SSO session. Cloudflare Access lets you scope policies so interactive console traffic goes through Google while automated, token-authenticated callers are validated on their own credentials — so the SSO gate never breaks the deploy pipeline.</li>
  <li>Finally, the settings UI now reflects reality: it shows which identity provider you authenticated with and disables the email/password fields for SSO users, rather than presenting a dead form.</li>
</ul>

<p>The whole thing is feature-flagged, so the upstream password-login behaviour is still the default for anyone else running the code. It ships as a normal patched image through the same GHCR-to-Railway pipeline as every other fix.</p>

<h2 id="owning-a-fork-you-didnt-write">Owning a fork you didn’t write</h2>

<p>There’s a version of this story that reads as a cautionary tale about depending on startups. I don’t think that’s the lesson. Sequin was — is — a genuinely good piece of engineering, and the fact that it <em>could</em> be adopted, read, fixed, and extended with SSO is entirely because it was open source. A closed SaaS that shut down would have left nothing but a migration deadline.</p>

<p>The real lesson is that “using open source” and “owning open source” are different commitments, and the gap between them can close overnight. When it does, the codebases you can actually take over are the ones written clearly enough to understand under pressure. Sequin was. You read the supervision tree, see why a Redis blip cascaded, and fix it — in someone else’s code that has quietly become yours.</p>

<p>If you’re travelling Australia or New Zealand, the search that lands you at the right campsite is riding on this pipeline. <a href="https://apps.apple.com/app/campermate/id578975305">Grab CamperMate on iOS</a> or <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a> — free, no account required.</p>

<hr />

<p><em>Header photo by <a href="https://unsplash.com/@v2osk">v2osk</a> on <a href="https://unsplash.com">Unsplash</a>.</em></p>]]></content><author><name>Isaac Rowntree</name></author><category term="engineering" /><category term="sequin" /><category term="cdc" /><category term="change-data-capture" /><category term="postgres" /><category term="elixir" /><category term="redis" /><category term="dragonfly" /><category term="cloudflare-access" /><category term="railway" /><category term="open-source" /><category term="campermate" /><category term="typesense" /><summary type="html"><![CDATA[Adopting Sequin — an open-source Postgres change-data-capture engine — after the company behind it wound down: what I use it for, the Redis mutex bug that let a Dragonfly redeploy take the whole thing down, the fix, and adding Cloudflare Access SSO to a fork I now help maintain.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/taking-over-sequin.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/taking-over-sequin.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Offline maps that look like 2026, not 2013 — a vector→raster MBTiles pipeline</title><link href="https://zackdesign.biz/offline-maps-vector-raster-mbtiles/" rel="alternate" type="text/html" title="Offline maps that look like 2026, not 2013 — a vector→raster MBTiles pipeline" /><published>2026-05-28T00:00:00+00:00</published><updated>2026-05-28T00:00:00+00:00</updated><id>https://zackdesign.biz/offline-maps-vector-raster-mbtiles</id><content type="html" xml:base="https://zackdesign.biz/offline-maps-vector-raster-mbtiles/"><![CDATA[<p>Most “offline maps” tutorials route you through one of two corners. <strong>Corner A</strong> is a 2013-era Mapnik stack rendering OSM-Carto — beautiful in its day, but the day is over. <strong>Corner B</strong> is a paid Mapbox or MapTiler subscription that solves the aesthetic problem and bills you for the privilege. There’s a third corner that no tutorial walks you to: a fully self-hosted vector-to-raster pipeline using modern open-source tools, producing tiles that look like Mapbox or Apple Maps, hosted on object storage with free egress.</p>

<p>This is the pipeline I built to ship offline basemaps for <a href="https://campermate.com">CamperMate</a> — the go-to free-camping and campground app across Australia and New Zealand, <a href="https://apps.apple.com/app/campermate/id578975305">iOS</a> and <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a>, 1M+ downloads, made at <a href="https://triptechtravel.com">Triptech Travel</a>. Users are in Fiordland, Kakadu, the Pilbara, the Tasmanian highlands. Cell coverage is a luxury, not a baseline. If the map doesn’t work without bars, the app doesn’t work. The pipeline below is platform-agnostic — the output is a <code class="language-plaintext highlighter-rouge">.mbtiles</code> SQLite file that any client can read. I’ll walk through how it ships in a React Native consumer at the end, but the pipeline itself is independent of where the bytes are rendered.</p>

<!-- more -->

<h2 id="the-wrong-path-mapnik-with-osm-carto">The wrong path: Mapnik with OSM-Carto</h2>

<p>The first thing every “offline OSM tiles” guide tells you is to spin up <a href="https://hub.docker.com/r/overv/openstreetmap-tile-server/"><code class="language-plaintext highlighter-rouge">overv/openstreetmap-tile-server</code></a> — a Docker container with <code class="language-plaintext highlighter-rouge">osm2pgsql</code>, PostGIS, and Mapnik rendering the canonical <code class="language-plaintext highlighter-rouge">openstreetmap-carto</code> style. That’s what powers <code class="language-plaintext highlighter-rouge">openstreetmap.org</code>.</p>

<p>I tried it. The pipeline works, but the output looks like 2013. Olive landuse fills, mustard buildings, brick-coloured motorways, that classic OSM look that every modern map product has moved on from. It’s not what people expect when they tap “offline maps” in 2026.</p>

<p>It’s also a <em>single-stage</em> pipeline that’s deceptively hard to evolve. Want to tweak the aesthetic? You’re editing CartoCSS and re-baking from PostGIS. Want a different style entirely? You’re rebuilding the whole stack. Mapnik is excellent at what it does, but what it does is render in a tradition that no longer matches what mobile users see daily on Apple Maps and Google Maps.</p>

<h2 id="the-right-path-vector--raster-with-maplibre-gl-native">The right path: vector → raster with MapLibre GL Native</h2>

<p>The trick is to <strong>split rendering from data extraction</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                 (one-time per region)              (per style)
                       ↓                                ↓
   OSM PBF  →  planetiler  →  vector MBTiles  →  tileserver-gl  →  raster MBTiles
                                  (single source of truth)            (re-renderable
                                                                       any time)
</code></pre></div></div>

<p>Two stages. The vector MBTiles is a <em>neutral</em> intermediate — same data, no styling. The raster MBTiles is what your app loads. You can re-render the raster in any MapLibre GL style — Positron, OSM Bright, Voyager, Dark Matter, a custom one — without touching the data pipeline.</p>

<p>Stage 1 (<code class="language-plaintext highlighter-rouge">planetiler</code>) is <strong>minutes</strong>. Stage 2 (<code class="language-plaintext highlighter-rouge">tileserver-gl</code>) is <strong>CPU-bound rendering</strong> — minutes for a city, hours for a country. Both run from Docker, both are open source, neither requires a third-party API key.</p>

<p>The rendering engine is <a href="https://github.com/maplibre/maplibre-native">MapLibre GL Native</a>, the same C++ engine that powers Mapbox GL JS and MapLibre GL JS on the web. That’s why the output looks identical to a modern web map — because it <em>is</em> a modern web map, rendered offline.</p>

<h2 id="the-tools">The tools</h2>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Job</th>
      <th>License</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://download.geofabrik.de">Geofabrik</a></td>
      <td>OSM PBF source data, per country and per state</td>
      <td>ODbL</td>
    </tr>
    <tr>
      <td><a href="https://osmcode.org/osmium-tool/"><code class="language-plaintext highlighter-rouge">osmium-tool</code></a></td>
      <td>Slice country PBFs into city/region bboxes</td>
      <td>GPL-3</td>
    </tr>
    <tr>
      <td><a href="https://github.com/onthegomap/planetiler"><code class="language-plaintext highlighter-rouge">planetiler</code></a></td>
      <td>OSM PBF → vector MBTiles (OpenMapTiles schema)</td>
      <td>Apache-2</td>
    </tr>
    <tr>
      <td><a href="https://github.com/maptiler/tileserver-gl"><code class="language-plaintext highlighter-rouge">tileserver-gl</code></a></td>
      <td>Vector MBTiles + GL style → raster PNG via MapLibre GL Native</td>
      <td>BSD-2</td>
    </tr>
    <tr>
      <td><a href="https://developers.google.com/speed/webp"><code class="language-plaintext highlighter-rouge">cwebp</code></a> (libwebp)</td>
      <td>Re-encode rendered PNGs to WebP q80 — ~5× smaller (see below)</td>
      <td>BSD</td>
    </tr>
    <tr>
      <td><a href="https://github.com/openmaptiles/fonts">OpenMapTiles fonts</a></td>
      <td>Pre-built glyph PBFs for label rendering</td>
      <td>OFL</td>
    </tr>
    <tr>
      <td><a href="https://github.com/openmaptiles">OpenMapTiles styles</a></td>
      <td>Free MapLibre GL styles (Positron, OSM Bright, Dark Matter)</td>
      <td>BSD-3</td>
    </tr>
  </tbody>
</table>

<p>All free. No keys. No bills. The whole stack runs on a MacBook.</p>

<h2 id="the-build-script">The build script</h2>

<p>The CamperMate offline-tiles build script is ~200 lines of bash that wires those tools together. Inputs: a region name, a Geofabrik path, a bbox, a max-zoom, a style name. Output: a single <code class="language-plaintext highlighter-rouge">.mbtiles</code> file ready to upload.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># NZ South Island, z0–15, rendered in OSM Bright</span>
scripts/build-offline-tiles.sh nz-south <span class="se">\</span>
  australia-oceania/new-zealand <span class="se">\</span>
  <span class="s1">'166.4,-47.3,174.5,-40.4'</span> <span class="se">\</span>
  15 <span class="se">\</span>
  osm-bright
</code></pre></div></div>

<p>The pipeline:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Cache the source PBF (one-time per Geofabrik region)</span>
curl <span class="nt">-fL</span> <span class="nt">-o</span> offline-tiles/pbf/nz.osm.pbf <span class="se">\</span>
  https://download.geofabrik.de/australia-oceania/new-zealand-latest.osm.pbf

<span class="c"># 2. Slice by bbox (skipped when Geofabrik already has per-state PBFs)</span>
osmium extract <span class="nt">--bbox</span><span class="o">=</span>166.4,-47.3,174.5,-40.4 <span class="nt">--strategy</span><span class="o">=</span>smart <span class="nt">--set-bounds</span> <span class="se">\</span>
  <span class="nt">-o</span> offline-tiles/pbf/nz-south-extract.osm.pbf <span class="se">\</span>
  offline-tiles/pbf/nz.osm.pbf

<span class="c"># 3. Generate vector MBTiles with planetiler (OpenMapTiles schema)</span>
docker run <span class="nt">--rm</span> <span class="nt">-e</span> <span class="nv">JAVA_TOOL_OPTIONS</span><span class="o">=</span><span class="s2">"-Xmx4g"</span> <span class="nt">-v</span> offline-tiles:/data <span class="se">\</span>
  ghcr.io/onthegomap/planetiler:latest <span class="se">\</span>
    <span class="nt">--osm_path</span><span class="o">=</span>/data/pbf/nz-south-extract.osm.pbf <span class="se">\</span>
    <span class="nt">--mbtiles</span><span class="o">=</span>/data/nz-south-vector.mbtiles <span class="se">\</span>
    <span class="nt">--bounds</span><span class="o">=</span>166.4,-47.3,174.5,-40.4 <span class="nt">--maxzoom</span><span class="o">=</span>15 <span class="nt">--download</span> <span class="nt">--force</span>

<span class="c"># 4. Render vector → raster via tileserver-gl</span>
docker run <span class="nt">-d</span> <span class="nt">--name</span> tileserver-gl-nz-south <span class="nt">-p</span> 8765:8080 <span class="se">\</span>
  <span class="nt">-v</span> offline-tiles:/data <span class="se">\</span>
  maptiler/tileserver-gl:latest <span class="se">\</span>
    <span class="nt">-c</span> /data/tileserver-config-nz-south.json

<span class="c"># 5. curl-loop every tile in the bbox; pack into raster MBTiles</span>
python3 render_and_pack.py nz-south <span class="s1">'166.4,-47.3,174.5,-40.4'</span> 15
</code></pre></div></div>

<p>A few non-obvious things that took me a day to learn:</p>

<ul>
  <li><strong>Planetiler needs Java 21+.</strong> If you have Zulu 17 installed for Android dev you’ll see <code class="language-plaintext highlighter-rouge">UnsupportedClassVersionError: class file version 65.0</code>. The Docker image avoids the JDK juggle.</li>
  <li><strong>OpenMapTiles styles ship with Maptiler-hosted source URLs.</strong> The default Positron <code class="language-plaintext highlighter-rouge">style.json</code> points at <code class="language-plaintext highlighter-rouge">api.maptiler.com</code> and needs a key. Rewrite <code class="language-plaintext highlighter-rouge">sources.openmaptiles.url</code> to <code class="language-plaintext highlighter-rouge">mbtiles://{openmaptiles}</code> and let tileserver-gl resolve it from the local MBTiles: <code class="language-plaintext highlighter-rouge">jq '.sources.openmaptiles = { type: "vector", url: "mbtiles://{openmaptiles}" }' positron.json &gt; positron.local.json</code>.</li>
  <li><strong>Fonts are not in the <code class="language-plaintext highlighter-rouge">openmaptiles/fonts</code> master branch.</strong> Master ships only TTF sources. The pre-built PBF glyph ranges are in the <a href="https://github.com/openmaptiles/fonts/releases/tag/v2.0">v2.0 release asset</a>. Without them tileserver-gl 500s on every tile containing a label, which is everything past z4.</li>
  <li><strong>Planetiler writes <code class="language-plaintext highlighter-rouge">bounds</code> and <code class="language-plaintext highlighter-rouge">center</code> metadata that breaks MapLibre GL Native.</strong> Strip them after planetiler runs: <code class="language-plaintext highlighter-rouge">sqlite3 *.mbtiles "DELETE FROM metadata WHERE name IN ('bounds', 'center');"</code>.</li>
</ul>

<h2 id="shipping-it-in-a-react-native-app">Shipping it in a React Native app</h2>

<p>The pipeline above is platform-agnostic — the output is just an MBTiles file. Web clients can read it via MapLibre GL JS + the <a href="https://github.com/maplibre/maplibre-gl-mbtiles"><code class="language-plaintext highlighter-rouge">mbtiles</code> protocol plugin</a>. Native iOS / Android can read it via their SQLite stack directly. Flutter has <a href="https://docs.fleaflet.dev/"><code class="language-plaintext highlighter-rouge">flutter_map</code></a> with MBTiles plugins. The thing that needs care is <em>how</em> the consumer reads tile bytes from the archive.</p>

<p>For CamperMate’s React Native app, the existing map stack is <code class="language-plaintext highlighter-rouge">react-native-maps</code>, which wraps Google Maps (Android) and Apple MapKit (iOS). Both expose a <code class="language-plaintext highlighter-rouge">&lt;UrlTile&gt;</code> primitive — but it expects an HTTP URL template:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">UrlTile</span> <span class="na">urlTemplate</span><span class="p">=</span><span class="s">"https://server/{z}/{x}/{y}.png"</span> <span class="p">/&gt;</span>
</code></pre></div></div>

<p>That’s fine for online tile servers. For an offline <code class="language-plaintext highlighter-rouge">.mbtiles</code> archive, there are three obvious options, all bad:</p>

<ol>
  <li><strong>Pre-extract <code class="language-plaintext highlighter-rouge">{z}/{x}/{y}.png</code> files and use <code class="language-plaintext highlighter-rouge">&lt;LocalTile&gt;</code>.</strong> Loses MBTiles’ single-file storage win and doesn’t work over a CDN.</li>
  <li><strong>Run a localhost HTTP server inside the app</strong> that serves tiles from the archive on demand. Adds startup cost, port management, battery, and JS-bridge contention per tile.</li>
  <li><strong>Switch to MapLibre RN.</strong> Solves the problem natively. But it’s an entire map-view replacement, losing every line of code that touches markers, callouts, gesture handlers, and providers.</li>
</ol>

<p>The fourth option — and the one I shipped — is a <strong>small native patch</strong> to <code class="language-plaintext highlighter-rouge">react-native-maps</code> that teaches <code class="language-plaintext highlighter-rouge">&lt;UrlTile&gt;</code> to read tile bytes directly from an MBTiles SQLite file via a custom <code class="language-plaintext highlighter-rouge">mbtiles://</code> URL scheme. The JSX surface stays identical:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">UrlTile</span>
  <span class="na">urlTemplate</span><span class="p">=</span><span class="s">"mbtiles:///var/.../offline/nz-north.mbtiles"</span>
  <span class="na">maximumNativeZ</span><span class="p">=</span><span class="si">{</span><span class="mi">15</span><span class="si">}</span>
  <span class="na">maximumZ</span><span class="p">=</span><span class="si">{</span><span class="mi">18</span><span class="si">}</span>
  <span class="na">shouldReplaceMapContent</span>
<span class="p">/&gt;</span>
</code></pre></div></div>

<p>The patch is ~750 lines across iOS and Android, applied via <a href="https://github.com/ds300/patch-package"><code class="language-plaintext highlighter-rouge">patch-package</code></a>. It teaches <code class="language-plaintext highlighter-rouge">MapTileProvider</code> (Android) and <code class="language-plaintext highlighter-rouge">AIRMapUrlTile</code> (iOS) to detect the <code class="language-plaintext highlighter-rouge">mbtiles://</code> URL scheme and route to a new SQLite-backed tile reader instead of the HTTP path. The internals — connection caching, TMS y-flip, overzoom via in-memory parent-bitmap reuse — are a separate post. The user-facing surface is exactly the snippet above. I’ll open-source the patch once it’s been in production for a few weeks; <a href="https://github.com/react-native-maps/react-native-maps/issues/5863">issue #5863</a> tracks it.</p>

<h2 id="region-splits-and-zoom-levels">Region splits and zoom levels</h2>

<p>Two practical decisions shape the file layout. <strong>Where to split</strong> comes from how Geofabrik ships data: country-level PBFs for NZ (split by bbox into north/south islands with <code class="language-plaintext highlighter-rouge">osmium extract</code>), per-state PBFs for AU (no slicing needed). The split should also match how users travel — for a campervan app, per-island and per-state is the right grain because that’s what people fly between.</p>

<p><strong>How deep to render</strong> is the other consequential decision. Each zoom level quadruples tile count, and real-world size grows faster than the math suggests because inked tiles compress worse than empty ones. I shipped NZ at native z15 (full Apple-Maps-style detail: trail heads, suburb names, motorway shields, ~420 MB per island after the optimisations below). AU at native z14 with overzoom (the patch stretches the largest available tile up to z18) is a 4× saving across an entire continent — road names still readable, dense urban POI labels the only loss. The asymmetry is deliberate: NZ is small enough that z15 doesn’t blow up storage, AU isn’t.</p>

<h2 id="the-dedup-schema--50-savings-for-free">The dedup schema — 50% savings for free</h2>

<p>The MBTiles spec defines two acceptable schemas. The naive one — a single <code class="language-plaintext highlighter-rouge">tiles(zoom_level, tile_column, tile_row, tile_data)</code> table — is what most ad-hoc scripts produce. The normalised one trades a tiny bit of read complexity for <strong>massive</strong> storage savings:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">images</span> <span class="p">(</span><span class="n">tile_id</span> <span class="nb">TEXT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span> <span class="n">tile_data</span> <span class="nb">BLOB</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="k">map</span> <span class="p">(</span><span class="n">zoom_level</span><span class="p">,</span> <span class="n">tile_column</span><span class="p">,</span> <span class="n">tile_row</span><span class="p">,</span> <span class="n">tile_id</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">UNIQUE</span> <span class="k">INDEX</span> <span class="n">map_index</span> <span class="k">ON</span> <span class="k">map</span> <span class="p">(</span><span class="n">zoom_level</span><span class="p">,</span> <span class="n">tile_column</span><span class="p">,</span> <span class="n">tile_row</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">VIEW</span> <span class="n">tiles</span> <span class="k">AS</span>
  <span class="k">SELECT</span> <span class="k">map</span><span class="p">.</span><span class="n">zoom_level</span><span class="p">,</span> <span class="k">map</span><span class="p">.</span><span class="n">tile_column</span><span class="p">,</span> <span class="k">map</span><span class="p">.</span><span class="n">tile_row</span><span class="p">,</span> <span class="n">images</span><span class="p">.</span><span class="n">tile_data</span>
  <span class="k">FROM</span> <span class="k">map</span> <span class="k">JOIN</span> <span class="n">images</span> <span class="k">ON</span> <span class="n">images</span><span class="p">.</span><span class="n">tile_id</span> <span class="o">=</span> <span class="k">map</span><span class="p">.</span><span class="n">tile_id</span><span class="p">;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">tile_id</code> is <code class="language-plaintext highlighter-rouge">sha1(tile_data)</code>. Identical tiles — every “pure ocean” tile, every patch of empty desert at mid-zoom, every uniform Southern Alps slope at z15 — collapse to one row in <code class="language-plaintext highlighter-rouge">images</code>, with many rows in <code class="language-plaintext highlighter-rouge">map</code> pointing at the same tile_id.</p>

<p>The <code class="language-plaintext highlighter-rouge">tiles</code> VIEW makes this completely transparent to consumers. Any <code class="language-plaintext highlighter-rouge">SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?</code> works identically against either schema.</p>

<p>The measured impact on NZ (PNG tiles, before the WebP step below):</p>

<ul>
  <li><strong>nz-north</strong>: 716,859 tiles → 1.9 GB on disk (51% saving over flat schema)</li>
  <li><strong>nz-south</strong>: 859,891 tiles → 242,217 unique blobs (<strong>71.8% tile dedup rate</strong>), 1.8 GB on disk (57% saving)</li>
</ul>

<p>Why so high? A region the size of New Zealand has <em>enormous</em> repetition at mid-zooms — endless ocean tiles, identical bush-cover tiles in the Fiordland interior, hundreds of identical “purple Southern Alps shading” tiles. Dense urban tiles (Auckland CBD) are all unique and don’t dedup, but they’re a small fraction of any region’s total tile count.</p>

<p>Hashing every tile during pack adds CPU but it’s microseconds per tile — invisible compared to the actual rendering time. Reading via the VIEW adds one indexed JOIN which doesn’t measurably affect tile-fetch latency on mobile.</p>

<h2 id="webp-not-png--another-5-shrink">WebP, not PNG — another ~5× shrink</h2>

<p>The next surprise was the format choice. PNG is what tileserver-gl emits and what every MBTiles tutorial uses, but PNG is the wrong codec for inked map tiles. Roads, anti-aliased coastlines, gradient hillshading, transparent green parks — none of it palettises well, which is what PNG’s compression relies on. WebP’s lossy mode is designed for exactly this kind of mixed graphical content.</p>

<p>I sampled 1,000 random unique tiles from the dedup-packed TAS archive and compressed each as PNG (baseline), <code class="language-plaintext highlighter-rouge">pngquant --quality 75-95</code>, and WebP at four qualities:</p>

<table>
  <thead>
    <tr>
      <th>Codec</th>
      <th style="text-align: right">Size vs PNG</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PNG (baseline)</td>
      <td style="text-align: right">100%</td>
    </tr>
    <tr>
      <td>pngquant 75–95</td>
      <td style="text-align: right">29%</td>
    </tr>
    <tr>
      <td>WebP q75</td>
      <td style="text-align: right">16%</td>
    </tr>
    <tr>
      <td><strong>WebP q80</strong></td>
      <td style="text-align: right"><strong>19%</strong></td>
    </tr>
    <tr>
      <td>WebP q85</td>
      <td style="text-align: right">26%</td>
    </tr>
    <tr>
      <td>WebP q90</td>
      <td style="text-align: right">38%</td>
    </tr>
  </tbody>
</table>

<p>WebP at q80 beats pngquant at every setting tested. The visual difference at street zoom is invisible — labels stay crisp, terrain shading stays smooth. The end-to-end re-render of TAS confirmed it: <strong>376 MB → 61 MB</strong>.</p>

<p>The pipeline change is one line: after fetching the rendered PNG from tileserver-gl, pipe it through <code class="language-plaintext highlighter-rouge">cwebp -q 80</code> before hashing and inserting into the <code class="language-plaintext highlighter-rouge">images</code> table. Update the MBTiles <code class="language-plaintext highlighter-rouge">format</code> metadata from <code class="language-plaintext highlighter-rouge">png</code> to <code class="language-plaintext highlighter-rouge">webp</code> so the spec stays honest; consumers that ignore that field (like my native patch, which passes raw bytes straight to <code class="language-plaintext highlighter-rouge">UIImage</code> / <code class="language-plaintext highlighter-rouge">BitmapFactory</code>) don’t notice the change. Both platforms have decoded WebP natively for years — iOS 14+, Android API 14+.</p>

<p>End-to-end size for all 9 ANZ regions, walking through the optimisations:</p>

<table>
  <thead>
    <tr>
      <th>Pipeline state</th>
      <th style="text-align: right">Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Flat schema, PNG (naive baseline, projected)</td>
      <td style="text-align: right">~21 GB</td>
    </tr>
    <tr>
      <td>Dedup schema, PNG</td>
      <td style="text-align: right">10.7 GB</td>
    </tr>
    <tr>
      <td><strong>Dedup schema, WebP q80</strong></td>
      <td style="text-align: right"><strong>2.3 GB</strong></td>
    </tr>
  </tbody>
</table>

<p>About 9× smaller than the naive starting point, and within rounding error of the legacy ZIP-tier total CamperMate already shipped — the “5× bigger download” story is gone.</p>

<p>R2 cost: <strong>~$0.04/month storage</strong> for the whole tier, and <strong>egress to devices is free</strong> — the killer feature R2 has over S3. A user who only ever visits Tasmania downloads 61 MB once, free, never pays storage either. The “I’m doing all of NSW” worst case is now 305 MB. The biggest single download in the tier is the North Island at 444 MB.</p>

<h2 id="style-picks">Style picks</h2>

<p>For CamperMate I tested the free OpenMapTiles styles. All open-licensed, all render against the same vector MBTiles:</p>

<ul>
  <li><strong>Positron</strong> — minimal, white, designed as a backdrop for <em>other</em> content. Beautiful but wrong for an “offline map replacement” use case where the map <em>is</em> the content.</li>
  <li><strong>OSM Bright</strong> — what I shipped with. Coloured roads, green parks, blue water, motorway shields, full POI labels. Reads like Apple Maps in light mode.</li>
  <li><strong>Dark Matter</strong> — dark-mode equivalent of Positron. Future option for a night-mode toggle.</li>
</ul>

<p>The aesthetic decision changes which file you ship to users; it doesn’t change anything upstream. Vector MBTiles → re-render → upload. Hours, not days.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>If you’re building any kind of outdoor, overland, or regional travel app and your users care about offline coverage, this pipeline is repeatable. The tools are mature, the licensing is permissive (OSM is ODbL, the styles are BSD/MIT, planetiler is Apache-2, tileserver-gl is BSD-2), the storage is cheap, and the aesthetic is finally something you can put in a shipping app without an apology.</p>

<p>If you’re heading to Australia or New Zealand and want to see the pipeline in production, <a href="https://apps.apple.com/app/campermate/id578975305">grab CamperMate on iOS</a> or <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a> — free, no account required, offline maps under the “Downloads” tab. Your offline maps don’t have to look like 2013 anymore.</p>

<hr />

<p><em>Header photo by <a href="https://unsplash.com/@marekpiwnicki">Marek Piwnicki</a> on <a href="https://unsplash.com">Unsplash</a>.</em></p>]]></content><author><name>Isaac Rowntree</name></author><category term="engineering" /><category term="offline-maps" /><category term="mbtiles" /><category term="openmaptiles" /><category term="openstreetmap" /><category term="planetiler" /><category term="tileserver-gl" /><category term="maplibre" /><category term="cloudflare-r2" /><category term="react-native" /><category term="campermate" /><summary type="html"><![CDATA[How to ship offline basemaps with a modern MapLibre aesthetic (Positron, OSM Bright) from OpenStreetMap data: OSM PBF → planetiler → tileserver-gl → raster MBTiles → Cloudflare R2. Platform-agnostic, free, no API keys, no Mapbox bill. The pipeline I built for CamperMate.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/offline-maps-pipeline.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/offline-maps-pipeline.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Shutterdrop — wireless tethered phone camera for your Mac</title><link href="https://zackdesign.biz/shutterdrop/" rel="alternate" type="text/html" title="Shutterdrop — wireless tethered phone camera for your Mac" /><published>2026-04-22T00:00:00+00:00</published><updated>2026-04-22T00:00:00+00:00</updated><id>https://zackdesign.biz/shutterdrop</id><content type="html" xml:base="https://zackdesign.biz/shutterdrop/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/shutterdrop"><code class="language-plaintext highlighter-rouge">shutterdrop</code></a> — a wireless tethered camera that turns the phone in your pocket into a wifi shutter for your Mac. Tap the screen on your phone, the photo lands in a watched folder on your Mac a moment later. Like Capture One tether, but over wifi from your iPhone or Android instead of a USB DSLR. No cable, no cloud, no account.</p>

<!-- more -->

<h2 id="why-this-exists">Why this exists</h2>

<p>I take a lot of product photos for eBay listings — bike parts, electronics, miscellaneous resale. The iPhone in my pocket has a vastly better camera than my MacBook’s built-in webcam, but the friction of “shoot on phone → AirDrop → import to listing tool” was killing the throughput. Existing wireless tether tools either want a subscription, push photos through someone else’s cloud, or are tied to a specific desktop app I don’t use.</p>

<p>Shutterdrop is the smallest possible thing that solves the problem: tap shutter, file shows up. That’s it. The receiver writes straight to a watched folder, so whatever workflow you already have (Finder smart folder, Hazel rule, Lightroom auto-import, eBay listing CLI) just sees new files appear.</p>

<h2 id="what-its-like-to-use">What it’s like to use</h2>

<ol>
  <li>Start the receiver on your Mac. It prints a 6-digit pairing code in the terminal.</li>
  <li>Open the Shutterdrop app on your phone. It finds your Mac on the wifi automatically and asks for the code.</li>
  <li>Type the code once. You’re paired forever — your phone remembers your Mac.</li>
  <li>Frame the shot, tap anywhere on the camera preview, and a moment later the photo appears in <code class="language-plaintext highlighter-rouge">~/Pictures/Shutterdrop/</code> on your Mac. Drag it straight into your eBay listing, your Lightroom catalogue, or wherever you already work.</li>
</ol>

<p>If you walk out of wifi range mid-shoot, captures queue up on the phone and flush as soon as you’re back online. Nothing gets lost.</p>

<h2 id="whats-in-it">What’s in it</h2>

<ul>
  <li><strong>iPhone app</strong> for iOS 17+, with a manual lens picker on Pro phones (0.5× / 1× / 3×) and a built-in torch toggle. Photos are HEIC at full quality.</li>
  <li><strong>Android app</strong> for Android 8 and up. JPEG capture, edge-to-edge layout, accessible to TalkBack screen readers.</li>
  <li><strong>A small Mac receiver</strong> written in Python. It runs in the background, advertises itself on the local network, and drops every incoming photo into a folder of your choice. Linux works too.</li>
  <li><strong>Pairing is private.</strong> A one-time 6-digit code shown on your Mac, with rate limits and a 5-minute window so nobody on the same wifi can guess their way in. The shared key lives in your phone’s secure storage (iOS Keychain or Android EncryptedSharedPreferences).</li>
</ul>

<h2 id="status">Status</h2>

<p>Working end-to-end on both iPhone and Android, with an automated test suite for the Mac receiver that runs on every push. The wire protocol between phone and Mac is small enough that you could write your own receiver — drop incoming photos into S3, pipe them through <code class="language-plaintext highlighter-rouge">pngcrush</code>, auto-import to Lightroom, whatever you want. MIT-licensed, source on <a href="https://github.com/isaacrowntree/shutterdrop">GitHub</a>.</p>

<h2 id="under-the-hood-for-the-curious">Under the hood (for the curious)</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Phone (iOS or Android)              Mac (or Linux)
┌───────────────────────┐           ┌────────────────────────┐
│ Camera preview        │  HTTP     │ receiver.py            │   drop
│ Tap-to-capture (HEIC  ├──over────▶│ (Python stdlib +       ├──────▶  ~/Pictures/Shutterdrop/
│  on iOS / JPEG on     │  LAN +    │  zeroconf)             │
│  Android)             │  Bonjour  │ advertises             │
│ Offline outbox        │           │ _shutterdrop._tcp      │
│ Bonjour discovery     │           └────────────────────────┘
└───────────────────────┘
</code></pre></div></div>

<p>Three endpoints, that’s the whole protocol:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET  /health  → {"ok":true}                          unauthenticated
POST /pair    → {"code":"123456","peerName":"…"}     returns {"secret","peer"}
POST /submit  → multipart/form-data, "photo" part, Bearer auth required
</code></pre></div></div>

<p>Build details and architecture notes are in the <a href="https://github.com/isaacrowntree/shutterdrop">README</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="swift" /><category term="kotlin" /><category term="python" /><category term="ios" /><category term="android" /><category term="mac" /><category term="photography" /><category term="bonjour" /><category term="open-source" /><summary type="html"><![CDATA[An iOS + Android + Python receiver that turns your phone into a tap-and-drop wireless tether for your Mac. LAN + Bonjour, no cable, no cloud.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/shutterdrop.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/shutterdrop.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">bezant — typed IBKR access from Rust, HTTP, CLI, MCP, and TypeScript</title><link href="https://zackdesign.biz/bezant/" rel="alternate" type="text/html" title="bezant — typed IBKR access from Rust, HTTP, CLI, MCP, and TypeScript" /><published>2026-04-20T00:00:00+00:00</published><updated>2026-04-20T00:00:00+00:00</updated><id>https://zackdesign.biz/bezant</id><content type="html" xml:base="https://zackdesign.biz/bezant/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/bezant"><strong>bezant</strong></a> — a typed async Rust client for the Interactive Brokers Client Portal Web API, with HTTP, CLI, MCP, and TypeScript surfaces all generated from the same vendored OpenAPI spec. It’s the first Rust project we’ve open-sourced, and it exists because trading against IBKR from modern code shouldn’t mean hand-rolling 155 HTTP endpoints from a PDF.</p>

<!-- more -->

<h2 id="why-it-exists">Why it exists</h2>

<p>Interactive Brokers’ Client Portal Web API (CPAPI) is the gateway most retail-adjacent trading tools reach for — it covers accounts, positions, orders, market data, watchlists, scanners, PnL, and more. The surface area is <strong>155 paths, 167 methods, 1030 types</strong>. The official docs ship an OpenAPI spec, but the spec has real-world quirks: missing or duplicate <code class="language-plaintext highlighter-rouge">operationId</code>s, malformed <code class="language-plaintext highlighter-rouge">security[]</code> blocks, integer fields with floating-point example values, and a few other gremlins that break naive code generators.</p>

<p>Bezant vendors that spec, normalises it through a 13-step pipeline, and regenerates every client surface from a single command. When IBKR revises the spec, one <code class="language-plaintext highlighter-rouge">./scripts/codegen.sh</code> re-runs the whole thing and every language/runtime updates together.</p>

<h2 id="five-surfaces-one-spec">Five surfaces, one spec</h2>

<table>
  <thead>
    <tr>
      <th>Crate / package</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-core</code></strong></td>
      <td>Ergonomic async Rust facade — <code class="language-plaintext highlighter-rouge">Client</code>, session keepalive, health, WebSocket streaming, pagination, symbol cache, typed errors</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-api</code></strong></td>
      <td>Auto-generated Rust client covering every CPAPI endpoint</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-server</code></strong></td>
      <td>HTTP sidecar — exposes CPAPI as plain REST+JSON so any language can consume it</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-cli</code></strong></td>
      <td><code class="language-plaintext highlighter-rouge">bezant</code> CLI — <code class="language-plaintext highlighter-rouge">bezant health</code>, <code class="language-plaintext highlighter-rouge">bezant positions DU123456</code>, <code class="language-plaintext highlighter-rouge">bezant conid AAPL</code></td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-mcp</code></strong></td>
      <td>Model Context Protocol server — exposes IBKR as MCP tools for Claude Code, Cursor, Continue</td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">bezant-client</code></strong></td>
      <td>TypeScript client for Node / Deno / browser</td>
    </tr>
  </tbody>
</table>

<p>The MCP surface is the one that surprised us the most. Once it was there, driving IBKR from a conversation — <em>“what are my open positions in my paper account?”</em> — became a single <code class="language-plaintext highlighter-rouge">/plugin install</code> away. The same spec drove the typed Rust client, the CLI, and the TypeScript package. Zero duplication.</p>

<h2 id="rust-because-it-earns-the-weight">Rust, because it earns the weight</h2>

<p>Rust is new to our open-source lineup. We chose it for bezant specifically because:</p>

<ul>
  <li><strong>A long-running trading session wants to not crash.</strong> Rust’s memory safety and absence of GC pauses feel right for code that holds an authenticated session, streams over a WebSocket, and needs to keepalive a 5-minute-expiring cookie cleanly.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">reqwest</code> + <code class="language-plaintext highlighter-rouge">tokio</code> is genuinely pleasant.</strong> Strong types end-to-end, async streaming via <code class="language-plaintext highlighter-rouge">tokio-tungstenite</code>, and error handling via <code class="language-plaintext highlighter-rouge">thiserror</code> — the Rust HTTP/WebSocket story in 2026 is excellent.</li>
  <li><strong>Codegen is unforgiving.</strong> When you regenerate a client from a noisy third-party spec, the compiler catches mismatches the moment you rebuild. Dynamic languages find out at runtime.</li>
</ul>

<p>The ergonomic facade in <code class="language-plaintext highlighter-rouge">bezant-core</code> sits on top of the raw generated client so callers don’t have to think about method-naming quirks or type re-wrapping:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">use</span> <span class="nn">std</span><span class="p">::</span><span class="nn">time</span><span class="p">::</span><span class="n">Duration</span><span class="p">;</span>

<span class="nd">#[tokio::main]</span>
<span class="k">async</span> <span class="k">fn</span> <span class="nf">main</span><span class="p">()</span> <span class="k">-&gt;</span> <span class="nn">bezant</span><span class="p">::</span><span class="nb">Result</span><span class="o">&lt;</span><span class="p">()</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">client</span> <span class="o">=</span> <span class="nn">bezant</span><span class="p">::</span><span class="nn">Client</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="s">"https://localhost:5000/v1/api"</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">_keepalive</span> <span class="o">=</span> <span class="n">client</span><span class="nf">.spawn_keepalive</span><span class="p">(</span><span class="nn">Duration</span><span class="p">::</span><span class="nf">from_secs</span><span class="p">(</span><span class="mi">60</span><span class="p">));</span>
    <span class="n">client</span><span class="nf">.health</span><span class="p">()</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>

    <span class="k">let</span> <span class="n">positions</span> <span class="o">=</span> <span class="n">client</span><span class="nf">.all_positions</span><span class="p">(</span><span class="s">"DU123456"</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">aapl</span> <span class="o">=</span> <span class="nn">bezant</span><span class="p">::</span><span class="nn">SymbolCache</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="n">client</span><span class="p">)</span><span class="nf">.conid_for</span><span class="p">(</span><span class="s">"AAPL"</span><span class="p">)</span><span class="k">.await</span><span class="o">?</span><span class="p">;</span>
    <span class="nd">println!</span><span class="p">(</span><span class="s">"{} positions; AAPL = conid {aapl}"</span><span class="p">,</span> <span class="n">positions</span><span class="nf">.len</span><span class="p">());</span>
    <span class="nf">Ok</span><span class="p">(())</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-spec-normalisation-pipeline">The spec-normalisation pipeline</h2>

<p>The most unexpectedly interesting piece of this project is the 13-step spec-normalisation pipeline. IBKR’s published OpenAPI isn’t wrong — it’s <em>realistic</em>. Real specs have duplicate operation IDs, missing required fields, and security definitions that don’t validate.</p>

<p>Rather than patch-forward into our codegen, bezant normalises the spec <em>before</em> codegen runs. Each step is idempotent and documented:</p>

<ol>
  <li>Add missing <code class="language-plaintext highlighter-rouge">operationId</code>s deterministically from path + method</li>
  <li>De-duplicate the operationIds that IBKR repeats</li>
  <li>Repair malformed <code class="language-plaintext highlighter-rouge">security[]</code> blocks</li>
  <li>Coerce integer fields with float example values</li>
  <li>Upgrade OAS 3.0 → 3.1 where it matters for our generator</li>
  <li>…and eight more</li>
</ol>

<p>The output is a clean, modern OpenAPI 3.1 document that <strong>every</strong> downstream generator can consume without complaint. The full pipeline is documented at <a href="https://isaacrowntree.github.io/bezant/internals/normalisation.html">Spec normalisation</a> — if you have your own fights with a gnarly third-party spec, the pattern is worth stealing.</p>

<h2 id="testing-against-reality">Testing against reality</h2>

<p>34 tests across the workspace, all green in CI:</p>

<ul>
  <li><strong>Unit</strong> for the facade and the CLI</li>
  <li><strong>Snapshot tests</strong> keyed to real IBKR example payloads — catches upstream spec drift before users feel it</li>
  <li><strong>Integration</strong> against <code class="language-plaintext highlighter-rouge">wiremock</code> for fault-injection (session expiry, 5xx retries)</li>
  <li><strong>End-to-end</strong> through Docker Compose against a mocked Gateway</li>
</ul>

<p>The Docker Compose quickstart is one command: <code class="language-plaintext highlighter-rouge">docker compose up</code>, log in to the IBKR Gateway once in a browser, and the HTTP sidecar is live on <code class="language-plaintext highlighter-rouge">http://localhost:8080</code>.</p>

<h2 id="mcp-ibkr-as-a-tool-for-claude">MCP: IBKR as a tool for Claude</h2>

<p>One of the weirder, more fun surfaces is <code class="language-plaintext highlighter-rouge">bezant-mcp</code> — a Model Context Protocol server that exposes IBKR endpoints as MCP tools. Drop it into Claude Code or Cursor, and you can ask <em>“show me the PnL on my paper account this week”</em> and the model drives the actual CPAPI to answer. The MCP tools are generated from the same spec, so new IBKR endpoints become new MCP tools automatically.</p>

<h2 id="status-and-licensing">Status and licensing</h2>

<ul>
  <li><strong>Alpha — v0.1.</strong> Works end-to-end against IBKR paper accounts; API surface will evolve until v1.0</li>
  <li><strong>Dual-licensed MIT / Apache-2.0</strong> following Rust ecosystem convention</li>
  <li><strong>Not affiliated with Interactive Brokers</strong> — the vendored spec is IBKR’s IP, included under fair-use for interoperability</li>
  <li><strong>Docs:</strong> <a href="https://isaacrowntree.github.io/bezant/">isaacrowntree.github.io/bezant</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/isaacrowntree/bezant">github.com/isaacrowntree/bezant</a></li>
</ul>

<p>If you’re building a trading bot, an analytics tool, an AI-assistant-with-broker-access, or anything that wants typed access to IBKR without the PDF-reading phase — bezant is waiting. Contributions welcome, especially on the spec-normaliser and on new client languages.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="rust" /><category term="ibkr" /><category term="trading" /><category term="openapi" /><category term="mcp" /><category term="typescript" /><category term="interactive-brokers" /><category term="client-portal" /><summary type="html"><![CDATA[A Rust-first async client for the Interactive Brokers Client Portal Web API — with HTTP, CLI, MCP, and TypeScript surfaces auto-generated from one vendored OpenAPI spec.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/bezant.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/bezant.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introducing SessionHQ — our flagship SaaS</title><link href="https://zackdesign.biz/sessionhq-launch/" rel="alternate" type="text/html" title="Introducing SessionHQ — our flagship SaaS" /><published>2026-04-17T00:00:00+00:00</published><updated>2026-04-17T00:00:00+00:00</updated><id>https://zackdesign.biz/sessionhq-launch</id><content type="html" xml:base="https://zackdesign.biz/sessionhq-launch/"><![CDATA[<p>After months of design, engineering, and iteration with real studio operators, Zack Design is proud to launch <strong><a href="https://sessionhq.org">SessionHQ</a></strong> — the modern check-in platform for class-based studios. It is the most ambitious product we have ever shipped, and it now runs nightly check-ins at our founding partner <a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> in Port Macquarie.</p>

<!-- more -->

<h2 id="what-sessionhq-does">What SessionHQ does</h2>

<p>SessionHQ replaces the spreadsheets, paper sign-in sheets, and duct-taped Mindbody workarounds that most small studios tolerate because the alternatives are too expensive, too clunky, or too generic. We built it by sitting at the front desk on a Tuesday night and asking, <em>“what actually needs to happen here?”</em></p>

<p>The answer, it turns out, is:</p>

<ul>
  <li><strong>Members walk in and check in fast.</strong> PIN pad, NFC wristband tap, or QR scan from their phone. No app install required. No “where’s my card.”</li>
  <li><strong>Passes just work.</strong> Class packs, casual rates, unlimited passes. Credits deduct automatically on check-in. Cards-on-file auto-renew the moment a pack runs out.</li>
  <li><strong>Payments happen where the student is.</strong> Square integration handles card payments inline. PCI-compliant. No raw card numbers ever touch our servers.</li>
  <li><strong>Admins see the truth.</strong> Tonight’s attendance, revenue, unpaid check-ins, LTV, retention cohorts — all updating in real time.</li>
</ul>

<p>No per-member fees. No transaction surcharges on top of Square. One flat monthly subscription.</p>

<h2 id="the-technology-behind-it">The technology behind it</h2>

<p>SessionHQ is a serious piece of software infrastructure. A quick tour of the stack:</p>

<ul>
  <li><strong>Next.js 16 &amp; React 19</strong> on the frontend, with Tailwind 4 and a custom 19-primitive design system (not shadcn — we wanted the ownership).</li>
  <li><strong>Cloudflare Workers</strong> via OpenNext for the runtime. Global edge deployment, sub-100ms cold starts, one Worker cron handling pass-lifecycle, database backup, prune, and retention sweeps.</li>
  <li><strong>Supabase</strong> for auth, Postgres, realtime, and row-level security. Every tenant-owned table enforces <code class="language-plaintext highlighter-rouge">auth_tenant_id()</code> at the database layer — a studio <em>cannot</em> see another studio’s data, period.</li>
  <li><strong>Square</strong> for payments, with Supabase Vault for token storage and PCI-safe tokenisation.</li>
  <li><strong>Resend</strong> for lifecycle email, <strong>Sentry</strong> for observability, <strong>R2</strong> for storage, <strong>Playwright</strong> and <strong>Vitest</strong> for 800+ tests across unit, integration, and E2E.</li>
</ul>

<p>Multi-tenancy, GDPR-readiness (consent capture, data export, right-to-erasure, full audit trail), idempotency, rate limiting, feature flags — all in from day one, not bolted on later.</p>

<h2 id="why-we-built-it">Why we built it</h2>

<p>We have spent 20+ years building software for other people. SessionHQ is different: <strong>it is our product.</strong> We own the roadmap, the pricing, the customer relationship. We decide which features matter. We eat the bug reports.</p>

<p>It is also a proof point. We believe small businesses deserve software that is as thoughtfully engineered as anything the enterprise market gets — without the enterprise price tag, the 12-month implementation, or the 400-page MSA. SessionHQ is our demonstration that a small, focused team can ship serious SaaS.</p>

<h2 id="founding-partner-havana-on-the-hastings">Founding partner: Havana on the Hastings</h2>

<p>SessionHQ did not launch in a vacuum. It launched with a customer.</p>

<p><a href="https://www.havanahastingsdance.com.au/">Havana on the Hastings</a> is Port Macquarie’s Latin dance community — Cuban salsa, bachata, urban kiz, and rueda (the dance that brought founders Mike and Kellie together). They run on passes, practicas, and real connection, with the warmth of a studio where “everyone starts somewhere” is not just a slogan but a weekly reality.</p>

<p>They were already operating on the pass system that SessionHQ is built around. Partnering with them meant we did not have to guess what studio operators needed — we had one telling us, in real time, what worked and what did not. Every feature in SessionHQ has been stress-tested at their front desk on a Tuesday night.</p>

<p>If you are in Port Macquarie and want to dance, <a href="https://www.havanahastingsdance.com.au/classes">drop in</a>. Absolute beginners are welcome every week.</p>

<h2 id="whats-next">What’s next</h2>

<p>SessionHQ is onboarding new studios now. If you run a dance studio, gym, yoga or pilates studio, martial arts school, or climbing gym — or if you know someone who does — we would love to talk.</p>

<ul>
  <li><strong>Visit</strong> <a href="https://sessionhq.org">sessionhq.org</a> to see the product.</li>
  <li><strong>Request access</strong> on the site, or <strong>book a 15-minute demo</strong> via <code class="language-plaintext highlighter-rouge">info@sessionhq.org</code>.</li>
  <li><strong>Founding-studio pricing is locked in</strong> for the studios who sign on before general availability.</li>
</ul>

<p>This is the start of something we are going to spend years building on. Thanks for being here for the beginning.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="product" /><category term="sessionhq" /><category term="saas" /><category term="nextjs" /><category term="supabase" /><category term="cloudflare" /><category term="square" /><category term="product-launch" /><summary type="html"><![CDATA[SessionHQ — our modern multi-tenant check-in platform for dance studios, gyms, and martial arts schools — is live, with founding partner Havana on the Hastings.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/sessionhq-launch.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/sessionhq-launch.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Stamp Scanner — iPhone + Mac + SAM 3 for cataloguing stamp collections</title><link href="https://zackdesign.biz/stamp-scanner/" rel="alternate" type="text/html" title="Stamp Scanner — iPhone + Mac + SAM 3 for cataloguing stamp collections" /><published>2026-04-16T00:00:00+00:00</published><updated>2026-04-16T00:00:00+00:00</updated><id>https://zackdesign.biz/stamp-scanner</id><content type="html" xml:base="https://zackdesign.biz/stamp-scanner/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/stamp-scanner"><code class="language-plaintext highlighter-rouge">stamp-scanner</code></a> — a two-device workflow for cataloguing stamp collections. The iPhone acts as a tethered macro scanner. The Mac runs SAM 3 segmentation, perceptual-hash deduplication, rotation correction, and a local Qwen3-VL for identification. Everything lives in a queryable SQLite library you can point external tools at.</p>

<!-- more -->

<h2 id="the-architecture-in-ascii">The architecture, in ASCII</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iPhone (ios-app/)                    Mac (mac-app/)                 Python (tools/)
┌───────────────────┐   HTTP over    ┌───────────────────┐   file   ┌─────────────────┐
│ Capture (HEIC)    ├───LAN+Bonjour─▶│ PhoneIngestServer ├──drop───▶│ sam_worker.py   │
│ MotionGate        │                │ (accepts uploads) │          │ SAM 3 + dedup   │
│ Lens picker       │                └───────────────────┘          │ + white balance │
└───────────────────┘                         │                     └────────┬────────┘
                                              │                              │ writes
                                              ▼                              ▼
                                     ┌────────────────────┐         ┌─────────────────────┐
                                     │ SwiftUI library UI │◀──GRDB──│ library.sqlite      │
                                     │ grid · detail      │         │ (~/Library/App Sup) │
                                     │ rotate · identify  │         └──────────▲──────────┘
                                     │ colnect lookup     │                    │ writes
                                     └────────────────────┘                    │
                                                │ spawns                       │
                                                ▼                              │
                                     ┌────────────────────┐                    │
                                     │ orientation_worker │───── Ollama ───────┤
                                     │   (Qwen3-VL)       │                    │
                                     │ colnect_lookup.py  │───── HTTP ─────────┘
                                     └────────────────────┘
</code></pre></div></div>

<h2 id="the-data-flow">The data flow</h2>

<ol>
  <li><strong>iPhone captures HEIC.</strong> <code class="language-plaintext highlighter-rouge">MotionGate</code> waits for the phone to be steady (accelerometer settled) before taking the shot, the lens picker selects the macro-capable camera, and the captured HEIC is uploaded over Bonjour/LAN to the paired Mac.</li>
  <li><strong>Mac receives it.</strong> <code class="language-plaintext highlighter-rouge">PhoneIngestServer</code> — a SwiftUI app wrapping a tiny HTTP listener — drops the file into <code class="language-plaintext highlighter-rouge">.run/sam_inbox/</code>.</li>
  <li><strong>SAM 3 segments the stamp.</strong> <code class="language-plaintext highlighter-rouge">sam_worker.py</code> runs the Segment Anything 3 model to cut the stamp out of the page, perceptual-hashes it to detect duplicates already in the library, warps it square, and white-balances against the untouched corners of the page.</li>
  <li><strong>SQLite writes.</strong> The segmented, deduplicated, white-balanced stamp lands in <code class="language-plaintext highlighter-rouge">library.sqlite</code> via a GRDB schema.</li>
  <li><strong>SwiftUI UI renders.</strong> The Mac app exposes a grid, a detail view, rotation tools, and “identify” / “Colnect lookup” buttons.</li>
  <li><strong>Identification is VLM-driven.</strong> Hitting “identify” spawns <code class="language-plaintext highlighter-rouge">orientation_worker</code> against a local Ollama-hosted Qwen3-VL instance. Hitting “Colnect lookup” queries the Colnect catalogue API for an official ID match.</li>
</ol>

<h2 id="why-two-devices">Why two devices</h2>

<p>Because an iPhone’s macro camera + image signal processor is genuinely excellent at stamp-sized subjects — better than a flatbed scanner at 1200 dpi for small dense subjects, and much faster. A Mac, meanwhile, is the right place for the heavy lifting: SAM 3 wants a GPU, the local VLM wants 20 GB of unified memory, and GRDB + SwiftUI want a real filesystem and a large screen. Splitting capture from processing plays to each device’s strengths.</p>

<h2 id="why-local">Why local</h2>

<p>A stamp collection is personal. You do not want to upload it to a third-party cataloguing service that might vanish in two years or quietly start charging a subscription. Local models, local SQLite, local UI. The only optional outbound call is the Colnect catalogue API, and that is a lookup against their public IDs — no collection data leaves your Mac.</p>

<h2 id="status">Status</h2>

<p>Working end-to-end for single-subject captures, deduplication, rotation, and VLM-based identification. Full architecture and build instructions in the <a href="https://github.com/isaacrowntree/stamp-scanner">README</a>. If you have a collection that deserves better than a spreadsheet, this is a solid starting point.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="ai" /><category term="swift" /><category term="python" /><category term="ios" /><category term="mac" /><category term="sam" /><category term="vlm" /><category term="philately" /><category term="local-ai" /><category term="open-source" /><summary type="html"><![CDATA[A two-device workflow that turns an iPhone into a macro scanner and a Mac into a SAM-3 segmentation, deduplication, and VLM identification pipeline for philately.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/stamp-scanner.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/stamp-scanner.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">bike-shock-planner — test-driven MTB shock fitment modelling</title><link href="https://zackdesign.biz/bike-shock-planner/" rel="alternate" type="text/html" title="bike-shock-planner — test-driven MTB shock fitment modelling" /><published>2026-04-12T00:00:00+00:00</published><updated>2026-04-12T00:00:00+00:00</updated><id>https://zackdesign.biz/bike-shock-planner</id><content type="html" xml:base="https://zackdesign.biz/bike-shock-planner/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/bike-shock-planner"><code class="language-plaintext highlighter-rouge">bike-shock-planner</code></a> — a <strong>test-driven, code-as-data</strong> planner for mountain bike rear shock replacements, coil conversions, and ebike suspension builds. It began as “can I fit a coil shock to a 2013 Trek Fuel EX 5 ebike conversion?” and grew into a reusable framework that models rear suspension geometry, shock fitment, spring rates, frame clearance, conversion hardware, and global sourcing paths for <em>any</em> bike.</p>

<!-- more -->

<h2 id="it-is-not-a-bike-specific-script">It is not a bike-specific script</h2>

<p>The 2013 Fuel EX 5 is the first “recipe” — a self-contained config describing one bike, one rider, and a set of candidate parts. Everything is written so you can drop in a new recipe for your own frame and the same fit-check and spring-rate logic runs against it. That is the whole point of the project: a single, testable model of rear-shock dimensions and fitment rules, with as many recipes layered on top as people are willing to contribute.</p>

<h2 id="who-it-is-for">Who it is for</h2>

<ul>
  <li><strong>DIY mechanics</strong> restoring an old MTB frame and trying to work out whether a modern shock will bolt up.</li>
  <li><strong>Ebike converters</strong> putting a mid-drive motor on a non-ebike frame and needing to recalculate spring rates for the extra mass and torque.</li>
  <li><strong>Frame hunters</strong> cross-checking a secondhand frame’s shock spec against catalog reality before buying.</li>
  <li><strong>Bike shops</strong> who want a reusable, forkable model of rear-shock dimensions — the catalog is just TypeScript, extend it for whatever you stock and rerun the tests to lint your inventory against real frames.</li>
  <li><strong>Anyone</strong> who has spent hours in a Trek fitment PDF trying to work out whether a shock advertised as “7.25×2.0 imperial” fits their old DRCV mount. (Spoiler: only via a conversion kit.)</li>
</ul>

<h2 id="what-it-does">What it does</h2>

<ul>
  <li><strong>Bike model.</strong> Eye-to-eye, stroke, mount styles, eyelet widths, bolt sizes, leverage ratio, progression, and the frame clearance envelope — all captured in code.</li>
  <li><strong>Shock catalog.</strong> Aftermarket shocks modelled as code, with body dimensions, piggyback status, coil spring rate range, Australian sourcing notes, and verified product URLs.</li>
  <li><strong>Fit check.</strong> Frame slot × candidate shock returns each dimensional mismatch separately — eye-to-eye, stroke, upper/lower eyelet width, bolt sizes, mount styles, body length, body diameter, reservoir clearance. No yes/no black boxes.</li>
  <li><strong>Conversion kits.</strong> Kits that rewrite a shock’s mounting hardware are modelled as functions that transform a candidate. So you can ask “does this imperial shock fit if I use the Shockcraft Deaktiv kit?” and get a real answer.</li>
  <li><strong>Spring-rate calculator.</strong> A <em>practical</em> formula that accounts for rear weight distribution — not the theoretical Fox “quick formula” that overshoots real-world spring picks by 40%.</li>
  <li><strong>Ebike load correction.</strong> Weights 40% of battery + motor mass onto the rear shock and adds a high-torque correction for ≥100 Nm motors.</li>
  <li><strong>Progression flag.</strong> Warns when a frame’s linkage does not really want a coil — e.g. Trek’s Full Floater is only ~13% progressive and is tuned for a DRCV air spring, so a linear coil will bottom harshly.</li>
  <li><strong>Documented-build flag.</strong> If no published build exists for the exact frame generation, every candidate gets an <em>experimental</em> warning.</li>
  <li><strong>Research library.</strong> Verified references to conversion kits, manufacturer product pages, global retailers, used-market venues, forum threads, and vendor email contacts — with tests enforcing that every link is HTTPS and every group is populated.</li>
  <li><strong>Pivot hardware model.</strong> OEM bearing/bolt spec plus a four-step health check so you can decide whether a full frame rebuild is required alongside the shock swap.</li>
</ul>

<h2 id="status-today">Status today</h2>

<p>Primarily a <strong>2013 Trek Fuel EX 5</strong> model. The coil catalog includes Push ElevenSix (the only currently-buildable imperial 7.25×2.0 coil in April 2026), plus Marzocchi Bomber CR, Fox DHX2, DVO Jade X, MRP Hazzard Coil, and Cane Creek DB Coil IL entries marked used-market-only. The air catalog includes Fox Float X2, RockShox Super Deluxe Ultimate, and Marzocchi Bomber Air. Real VALT Progressive sizes are captured with the 45 mm stroke that fits inside a 50 mm shock; Sprindex 55 mm is flagged as not-fitting. The conversion kit catalog covers Offset Bushings, Shockcraft Deaktiv, an unpublished custom-machine path for Huber Bushings, plus a speculative metric-to-Trek kit flagged <code class="language-plaintext highlighter-rouge">publishedSku: false</code> so the test suite warns on it.</p>

<h2 id="why-code-as-data">Why code-as-data</h2>

<p>Because every existing shock “compatibility chart” is a PDF, and PDFs cannot be run against a test suite. If you model the data in TypeScript, the test suite can assert things like “no reservoir clash on any frame in the catalog”, “every link in the research library is reachable”, and “every catalog entry has a spring rate range if it is a coil”. That turns a messy research task into something a contributor can submit a pull request against. Source on <a href="https://github.com/isaacrowntree/bike-shock-planner">GitHub</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="typescript" /><category term="bikes" /><category term="mtb" /><category term="suspension" /><category term="testing" /><category term="open-source" /><summary type="html"><![CDATA[A TypeScript framework for modelling rear-shock fitment, coil conversions, ebike spring rates, and global parts sourcing for any mountain bike — starting with a 2013 Trek Fuel EX 5.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/bike-shock-planner.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/bike-shock-planner.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>