<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Mosfetti]]></title><description><![CDATA[Mosfetti]]></description><link>https://mosfetti.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 21:07:14 GMT</lastBuildDate><atom:link href="https://mosfetti.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Moving a WordPress comic theme off WooCommerce products, and the picker bug that wiped the featured issue]]></title><description><![CDATA[I build classic (PHP template) WordPress themes for comic publishers. Two of them are Mythos Comics and Inkline Comics. This post covers three changes from…]]></description><link>https://mosfetti.hashnode.dev/moving-a-wordpress-comic-theme-off-woocommerce-products-and-the-picker-bug-that-wiped-the-featured-issue</link><guid isPermaLink="true">https://mosfetti.hashnode.dev/moving-a-wordpress-comic-theme-off-woocommerce-products-and-the-picker-bug-that-wiped-the-featured-issue</guid><category><![CDATA[WordPress]]></category><category><![CDATA[PHP]]></category><category><![CDATA[woocommerce]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Mosfetti]]></dc:creator><pubDate>Fri, 23 Oct 2026 16:12:25 GMT</pubDate><content:encoded><![CDATA[<p>I build classic (PHP template) WordPress themes for comic publishers. Two of them are Mythos Comics and Inkline Comics. This post covers three changes from their October releases (a native comic post type, a lossless migration and a self-filling "Latest Issues"), plus the bug that taught me the most.</p>
<h2>The problem with "a comic is a product"</h2>
<p>Mythos 1.0.0 stored each issue as a WooCommerce product, and the theme declared <code>Requires Plugins: woocommerce</code>. A publisher who just wanted to <em>publish</em> comics was stuck running a store.</p>
<p>In 1.1.0, comics became first-class content:</p>
<ul>
<li>a <code>mythos_comic</code> post type at <code>/comics/</code></li>
<li><code>mythos_series</code> (<code>/series/</code>) and <code>mythos_creator</code> (<code>/creator/</code>) taxonomies, where a creator can carry a role such as Writer or Artist</li>
<li>an Issue details meta box for issue number, kicker, rack blurb, free-chapter link and story panels chosen through the media modal</li>
</ul>
<p>WooCommerce became optional. A comic can link to <strong>one</strong> product, picked from a searchable picker on the comic screen, and the same product can't be linked twice. Price and Add to cart only appear when WooCommerce is active and the product is public. When WooCommerce is off, the link shows as "kept" and saving never touches it, and old <code>/product/…</code> URLs redirect to the linked comic instead of falling through to the homepage.</p>
<p>Inkline goes about it differently. Its <code>ink_comic</code> post type, series and creators live in a companion plugin, <code>inkline-comics-core</code>, so the content survives a theme switch. Mythos registers its types in the theme itself, so switching themes hides the comics (the data stays), and the readme says so.</p>
<h2>Migrating products to comics without breaking anything</h2>
<p>Existing 1.0 sites already had their comics stored as products, so on first load 1.1.0 copies each comic product into a linked comic record. The rules I held myself to:</p>
<ol>
<li><strong>Delete nothing.</strong> Products, product meta and the 1.0 theme mods aren't touched, so rolling back to 1.0.0 still works. The old homepage values are also copied into a <code>mythos_legacy_backup</code> option before anything else runs.</li>
<li><strong>Never duplicate.</strong> If a product already has a comic, the migration links to it instead of copying again, so re-running is safe.</li>
<li><strong>Batch it, and lock it.</strong> The migration handles 200 products per request behind an option-based lock. A stale lock from a crashed request expires after two minutes:</li>
</ol>
<pre><code class="language-php">if ( ! add_option( 'mythos_migrate_lock', time(), '', false ) ) {
    $since = (int) get_option( 'mythos_migrate_lock' );
    if ( $since &amp;&amp; time() - $since &lt; 120 ) {
        return;
    }
    update_option( 'mythos_migrate_lock', time(), false );
}
</code></pre>
<ol start="4">
<li><strong>Only migrate actual comics.</strong> A product counts as a comic if it has Mythos issue fields, creator credits, the sample marker or a homepage selection. Merchandise like a tote bag is left alone, and a "Create comic record" row action covers anything the migration missed.</li>
</ol>
<p>Product categories become series with the same names and slugs, and galleries become story panels. Homepage links that pointed at a migrated product now point at its comic.</p>
<h2>Latest Issues should fill itself</h2>
<p>In both themes, "Latest Issues" used to be a list kept up by hand, so a new comic didn't appear until someone edited and saved the homepage.</p>
<p>Now it's a query that runs on every homepage request: published comics only, ordered by post date descending and then ID descending, three by default, with the featured comic optionally left out. In Inkline 1.1.1 it's <code>Homepage::latest_ids()</code>, and the count can be changed with the <code>ink_core_latest_issues_limit</code> filter (clamped to 1–12). Mythos offers the same 1–12 range in its homepage settings. Drafts, pending, private and trashed comics never show, and scheduled comics show up once WordPress publishes them. There's nothing to refresh.</p>
<p>Some people liked curating by hand, so I kept that option. Inkline has a <strong>Show newest comics automatically</strong> checkbox that's on by default. Upgraded sites keep their old hand-picked list for when the box is unticked. In Mythos, the old hand-numbered rack became a separate, optional <strong>Editor's picks</strong> shelf.</p>
<h2>The bug: saving a subtitle wiped the featured issue</h2>
<p>Here's the one that hurt. In Mythos 1.0.0, the homepage picker was a <code>&lt;select&gt;</code> holding the first 100 products. On a test site with 126 products, I changed <strong>only the subtitle</strong> and saved. <code>featured_id</code> went from 151 to 0, and the hand-picked rack went from four IDs to an empty array.</p>
<p>The cause: the saved featured issue was outside the first 100 products, so it wasn't among the <code>&lt;select&gt;</code> options, and the save wrote the empty result over the stored value. The same happened when WooCommerce was off. An unrelated save destroyed data the user never touched.</p>
<p>The fix in 1.1.0 had three parts:</p>
<ul>
<li><strong>The browser's list of options no longer decides what gets saved.</strong> Selected IDs live in hidden inputs, and the visible picker is an AJAX search over every comic, 20 per page, by title or ID.</li>
<li><strong>If a field is missing from the form, the stored value stays.</strong> The save handler only changes a selection when its field is actually in the POST data.</li>
<li><strong>Unchanged IDs are trusted.</strong> If the submitted ID equals the stored one, it's kept even if it can't be looked up right now, for example while WooCommerce is off. Only a <em>new</em> selection has to be a valid comic.</li>
</ul>
<p>Before release, I ran the fix against the scenarios that matter: an in-place upgrade from 1.0.0, WooCommerce deactivated, a fresh install with no store, WooCommerce added later, and a featured comic picked from page 6 of 6 with 110 comics. Each run was followed by an unrelated save to confirm nothing got cleared.</p>
<h2>Takeaways</h2>
<ul>
<li>Don't make content depend on a commerce plugin if you can avoid it. Link to products instead.</li>
<li>Migrations should copy and link, never move or delete, and be safe to run twice.</li>
<li>Lists you can compute ("newest issues") shouldn't be stored by hand.</li>
<li>An empty form field doesn't mean the user cleared it. Treat a missing value as "keep what's stored".</li>
</ul>
<p>My other WordPress themes and tools are at <a href="https://store.mosfetti.com/">https://store.mosfetti.com/</a></p>
<p>— Mosfetti</p>
]]></content:encoded></item><item><title><![CDATA[Building an offline-first gear checkout tracker on an append-only event log]]></title><description><![CDATA[Small production crews (film, video, AV and events teams of roughly 4–12 people) all run into the same problem. A kit leaves the shop and comes back…]]></description><link>https://mosfetti.hashnode.dev/building-an-offline-first-gear-checkout-tracker-on-an-append-only-event-log</link><guid isPermaLink="true">https://mosfetti.hashnode.dev/building-an-offline-first-gear-checkout-tracker-on-an-append-only-event-log</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[PWA]]></category><category><![CDATA[offline first]]></category><category><![CDATA[indexeddb]]></category><dc:creator><![CDATA[Mosfetti]]></dc:creator><pubDate>Fri, 23 Oct 2026 01:20:46 GMT</pubDate><content:encoded><![CDATA[<p>Small production crews (film, video, AV and events teams of roughly 4–12 people) all run into the same problem. A kit leaves the shop and comes back partial, late or damaged. Then someone has to answer one question: <strong>can this kit go out on the next job?</strong></p>
<p>I built Countback to answer that question on one screen. These are the design decisions behind it and why I made them.</p>
<h2>Constraint #1: no server</h2>
<p>Countback has no backend, no accounts, no cloud and no telemetry: one device, one IndexedDB store.</p>
<p>It's an installable PWA. The service worker precaches the app shell, so after the first load it opens with the network off. App data never needed the network in the first place. The fetch handler also refuses to touch anything cross-origin:</p>
<pre><code class="language-js">if (url.origin !== self.location.origin) return;   // never proxy anything off-device
</code></pre>
<p>The front end is plain HTML, CSS and ES modules. The only dev dependency is esbuild, for bundling. Nothing extra runs at runtime.</p>
<h2>Constraint #2: no mutable status field</h2>
<p>I didn't give items a <code>status</code> column. A stored status can drift from what actually happened, and once that happens you can't tell who changed what.</p>
<p>Instead, the on-device document holds <code>kits[]</code>, <code>jobs[]</code> and <code>events[]</code>. Item state and the readiness verdict are <strong>derived</strong> from the events on every render. The event types are deliberately boring:</p>
<p><code>manifest_frozen</code>, <code>item_out</code>, <code>item_declared_returned</code>, <code>item_verified</code>, <code>item_missing</code>, <code>item_damaged</code>, <code>note</code>, <code>borrower_ack</code>, <code>correction</code>, <code>conflict_raised</code>, <code>resolution</code>.</p>
<p>Each event has the shape <code>{id, type, at, actor, jobId, itemId, payload, supersedes}</code>.</p>
<h2>Append-only, enforced in one place</h2>
<p>The append-only rule lives in one class that owns the events. <code>append</code> refuses an id that already exists with different content (re-appending identical content is a no-op), and there's no update, delete or splice method. The internal map is a private field, and <code>events()</code> returns deep-frozen copies:</p>
<pre><code class="language-js">append(input) {
  const event = deepFreeze(normaliseEvent(input));
  const existing = this.#byId.get(event.id);
  if (existing) {
    if (canonical(existing) === canonical(event)) return existing; // idempotent re-append
    throw new AppendOnlyViolation(/* ... */);
  }
  this.#byId.set(event.id, event);
  return event;
}
</code></pre>
<p>The IndexedDB adapter follows the same rule: events are keyed by id, and nothing in it ever issues a delete.</p>
<p>Mistakes still happen, so a fix is a new <code>correction</code> event that <em>supersedes</em> the old one. "Superseded" is a read-only view computed from later events, so nothing gets silently overwritten with last-write-wins.</p>
<h2>Declared is not verified</h2>
<p>This turned out to be the most useful rule in the app. When a borrower says "it's all back", that's a claim, not a check. Each item moves from <strong>declared returned</strong> to <strong>verified</strong>, or gets marked <strong>missing</strong> or <strong>damaged</strong> with a note. The UI never shows a declared-only return as verified, and a test covers that.</p>
<p>I made that difference visible per item on purpose, because that's where crews argue.</p>
<p>Checkout has the same kind of rule. A checkout isn't complete until a <code>borrower_ack</code> event exists, so acknowledgement is never implied. If the acknowledged hash doesn't match the frozen manifest, that's a blocker too.</p>
<h2>Freezing the outgoing manifest</h2>
<p>At checkout, Countback freezes the outgoing manifest and shows its SHA-256 content hash. Editing the kit later can't rewrite what actually left the shop.</p>
<p>To make hashes repeatable, every hash is taken over a canonical JSON form. Object keys are sorted, <code>undefined</code> is dropped and there's no whitespace, so two structurally identical values always produce byte-identical text. SHA-256 itself is a small dependency-free implementation, checked against the FIPS 180-4 test vectors. That way the browser, Node and the test suite all produce the same hashes.</p>
<h2>Ordering and merging without a server</h2>
<p>Without a server clock, ordering needs a rule. Event ids are zero-padded epoch milliseconds plus a suffix, so sorting ids alphabetically also sorts them by time. The hash chain is computed over that sorted order. Two devices that received the same events in a different order end up with the same chain head.</p>
<p>Importing an export merges by event id and never overwrites. If an incoming id matches an existing one but the content differs, it's rejected and reported instead of applied, and contradictory evidence becomes a conflict. An unresolved conflict blocks READY until someone appends an explicit, attributed <code>resolution</code> event. There's no hidden way to edit around it.</p>
<h2>The readiness board</h2>
<p>Everything above feeds one screen. Items show as OUT, DECLARED, VERIFIED, MISSING or DAMAGED, recomputed from the log on each render, with an overall READY or BLOCKED verdict. Every blocker has <strong>one owner and one due time</strong>: no frozen manifest, borrower hasn't acknowledged, unresolved conflict, item missing, item damaged, item still out, or a return that's declared but not verified.</p>
<h2>Tamper-evident exports</h2>
<p>The audit export comes as JSON and as printable HTML. It includes the manifest hash, each event's own content hash, its position in the chain, the chain head and the event count. Because of that, a verifier script can detect both a changed event (tampering) and a dropped event (truncation) offline:</p>
<pre><code class="language-bash">node tools/verify-evidence.mjs docs/evidence/evidence-sample.json
</code></pre>
<h2>Tests and reproducible builds</h2>
<p>The suite has 34 tests on <code>node:test</code> and doesn't need a browser. They cover these acceptance gates:</p>
<ul>
<li>two full checkout/return cycles with the network off</li>
<li>a return killed halfway through loses no events and gives the same verdict after restart</li>
<li>there's no edit or delete path</li>
<li>importing the same export twice is idempotent</li>
<li>tampering and truncation are detected</li>
<li>the demo path builds an identical chain everywhere</li>
</ul>
<p>The build honours <code>SOURCE_DATE_EPOCH</code>, and the release script pins it, so rebuilding unchanged sources gives byte-identical <code>dist/</code> output. The service worker's precache list is generated from the files the build actually emitted, so it can't drift out of sync with the build.</p>
<h2>What I'd tell someone building something similar</h2>
<ul>
<li>If an audit trail matters, derive state from events instead of storing it.</li>
<li>Put the append-only rule in code, in one class, and test that it holds.</li>
<li>Canonicalise before you hash, or two devices will disagree.</li>
<li>Keep "someone said so" and "someone checked" as separate states.</li>
</ul>
<p>Countback is MIT licensed and ships with full source and the test suite. If you want to look through it, it's here: <a href="https://store.mosfetti.com/product/countback/">https://store.mosfetti.com/product/countback/</a></p>
<p>— Mosfetti</p>
]]></content:encoded></item></channel></rss>