Konubinix' opinionated web of thoughts

Simple Pwa Organiser

Fleeting

I have a bunch of boxes storing a lot of small stuff. I’ve labelled them, but the labels are hard to read across the room and a pain to keep up to date when the contents shift. A photo of each box, plus a list of what’s inside, searchable from the phone in my pocket, would be enough to find anything I own without opening a single lid.

Let’s write a local-first PWA for that. Just photos, items, and a search — no framework on top, sync optional.

Live: https://sam.konubinix.eu/organiser/.

A note on reading the code blocks below. This is a literate document — prose and code interleaved, tangled into the running app at build time. Each block carries a #+name: line that gives it an identity; where you see <<some-name>> inside a block, it’s a slot — a hole left for one or more other blocks (named or referenced under some-name) to fill. The tangling step splices everything together; the export you’re reading keeps the slots visible on purpose, so the composition is part of what the code shows you.

choice of technology

Two prior attempts ground the choice.

The first was score counter tally, built on alpine + yjs (yjs is a CRDT library — a data structure that lets multiple devices edit the same document concurrently and merge the result without losing writes). The friction came from two reactivity systems competing: alpine drives the DOM from its own reactive store, yjs drives everything from its document, and the wiring between the two is where the bugs lived.

The second was scrutin de Condorcet randomisé entre amis, on petite-vue + automerge (another CRDT, different lineage from yjs). Slightly better — petite-vue is more forgiving than alpine — but the same fault line was there: the framework’s reactivity and the CRDT’s reactivity are not the same animal, and keeping them in step needed hacky magick at every seam.

The lesson I keep relearning is the same one: one reactive engine, no more. If the rendering layer wants to drive the DOM from its own model while the CRDT also wants to, the seam between them is where the bugs live and where I burn my evenings. I’ll accept a pure render function called from the store’s listener — declarative output that doesn’t pretend to own state — but I won’t accept a second engine.

The second thing I want is just as hard. This is a line-of-business app — lists, forms, CRUD on labelled boxes — and I want to read a card’s markup top to bottom in one piece, the way I read HTML on any real LoB codebase. Hand-rolled createElement chains and setAttribute calls are how I’d have done this in 2005, and they drown the reading every time I come back to the file.

So two picks, one per constraint.

The store is TinyBase. I need persistence (the boxes have to survive a tab close) and mergeable sync (the phone and the laptop have to agree), and TinyBase ships both. The thing I cared most about: its job ends at “a cell changed, here’s the new value.” It doesn’t try to render anything. That leaves me free to plug a renderer of my choosing.

The renderer is lit-html — and only its bare html + render primitives, not the full Lit component model with its own reactive controllers. The whole appeal is that render isn’t reactive: I call it, it patches the DOM, it goes away. My TinyBase listener rebuilds the template and calls render once; nothing else fires on its own. One loop, top to bottom. And because lit-html uses tagged template literals as ESM, “no JSX, no bundler” survives — the markup is HTML I can read, not a sequence of constructor calls.

React was the other obvious candidate. At this scale — forms, lists, CRUD on labelled boxes — the components + hooks + effect dependencies + build pipeline tax buys nothing.

The price of the TinyBase pick lands on the other side: it’s JS-only. If I later want a Python importer or a backend that reads the inventory, it’ll either parse the JSON the sync server writes to disk, or drive a JS runtime as a subprocess. Yjs and Automerge would have given me a real cross-language peer, but only by bringing back the reactivity duality I just walked away from. I’d rather take the language lock-in than pay that bug bill again.

Setting up the store

I want the first feature chapter to read like a feature, not like setup. So before any user-facing thing lands on screen, three foundation pieces have to be in place: I need both libraries loaded the way I said I’d load them, I need to agree with myself on the shape of a box and an item, and I need a boot sequence I can stop thinking about. Every later chapter will read and write through whatever I lay down here.

External libraries

I want TinyBase and lit-html on the page, and I committed to no build step. The browser can resolve bare specifiers (import {…} from 'tinybase') if I declare an import map, so that’s the smallest thing that gets me there.

<script type="importmap">
  {
    "imports": {
      "tinybase": "https://esm.sh/tinybase@5",
      "tinybase/persisters/persister-indexed-db": "https://esm.sh/tinybase@5/persisters/persister-indexed-db",
      "tinybase/synchronizers/synchronizer-ws-client": "https://esm.sh/tinybase@5/synchronizers/synchronizer-ws-client",
      "lit-html": "https://esm.sh/lit-html@3"
    }
  }
</script>

Store schema

What I want to remember is small: a box I can recognise on sight (so a photo, because the labels I’ve been using are unreadable across the room), and an item that names the thing I was looking for. An item has to know which box it lives in so the search can answer “where did I put that?”, and it has to allow no box at all so I can dump items into the system before I’ve decided where they go.

That gives me two TinyBase tables, both keyed by id. A box carries the photo, the basename of the file I picked (which I’ll need as the accessible label and as a hook the tests can grab), and an integer I’ll use to keep the boxes in the order I want — table iteration order doesn’t survive an edit-and-recreate, so I’d rather be explicit (see Reorder boxes). An item carries its name, its current boxId (empty when unassigned — see Drop an item into a box), optionally its own thumbnail for the chip (see Items with photos), and optionally a background colour I can use to group it visually (see Items with background colour).

Initial load

The store holds the things I want to remember after I close the tab. But the app has a second kind of state that has no business landing in IndexedDB: which form is open at the moment, the draft inside it, what I’ve typed in the search box, what the sync indicator is showing. Tomorrow’s “is the box form open?” answer should always be “no” regardless of what I did yesterday. I carry this ephemeral stuff in a plain object alongside the store, and I funnel every mutation through a single helper so I never forget to re-render after I touched it.

const ui = {
    formOpen: null,
    boxForm: {photo: '', photoName: '', editingId: ''},
    itemForm: {name: '', image: '', bgColor: '', editingId: '', nameError: false, boxId: ''},
    searchQuery: '',
    syncStatus: 'off',
    dragEnabled: false,
    contextMenu: null,
    catalogOpen: false,
};

function setUI(updates){
    Object.assign(ui, updates);
    renderApp();
}

Now the rendering. I want one entry point — one function that produces the whole dynamic body, top to bottom — because the moment I have two render functions racing each other I’m back in the bug zone the choice of TinyBase was supposed to walk me out of. Every feature chapter will plug into this entry point.

What I’ll plug into, exactly, is a set of named slots. The top bar has three: a status indicator on the left for the sync state, a row of action buttons in the middle, a search input on the right. The body has two: an overlay layer where the open form lives, and a content layer for the lists and the empty-state hint. Each later chapter pours its contribution into the slot it earns and nobody else’s.

And there’s exactly one place in the whole app where I let myself look up a DOM element by hand: at boot, to find the <main> the app will render into. After that, every element travels with the template that owns it.

const appRoot = document.getElementById('app');

function appTemplate(){
    return html`
      <<app-template-markup>>
    `;
}

function renderApp(){
    renderLit(appTemplate(), appRoot);
}

<header class="app-bar">
  <<app-bar-status>>
  <div class="app-bar-actions">
    <<app-bar-actions>>
  </div>
  <<app-bar-search>>
</header>
<<app-body-overlays>>
<<app-body-content>>
<<app-floating-controls>>

The boot sequence has one ordering trap, one type pick, and one small wrinkle for the test suite worth stating before the code.

The trap: if I wire the store listener before the persister has replayed yesterday’s state, renderApp fires on every replayed write and the first paint flashes through partial states. So I replay first, then wire the listener, then call renderApp once on the loaded state, then start saving forward mutations. The last thing I do is set data-app-ready on the body, which is the single signal my tests wait on before they start driving the page.

The wrinkle: TinyBase’s startAutoSave debounces writes by a tick or two, which is fine for production but means the persistence test would race the disk if I asked it to reload immediately after Save. Rather than dropping startAutoSave (it’s doing real work — handling the IndexedDB connection, coalescing burst writes), I add a parallel listener alongside it that calls persister.save() and bumps a data-persist-seq counter on the body once the write returns. The persistence test waits on that counter incrementing — a deterministic signal that the row is on disk — rather than guessing how long the debounce takes.

The type pick: MergeableStore, not the plain Store. The plain one wouldn’t survive the moment a second device starts writing — I need the hybrid-logical-clock stamps the WebSocket synchronizer reads (HLCs are a way to give every edit a total order across devices that have never spoken to each other, so the merge can decide who wins without a coordinator; see Sync across devices). Paying for the stamps now is cheaper than retrofitting them later.

import { createMergeableStore } from 'tinybase';
import { createIndexedDbPersister } from 'tinybase/persisters/persister-indexed-db';
import { createWsSynchronizer } from 'tinybase/synchronizers/synchronizer-ws-client';
import { html, render as renderLit } from 'lit-html';

const store = createMergeableStore();
const persister = createIndexedDbPersister(store, 'organiser');

await persister.startAutoLoad();
store.addTablesListener(renderApp);
renderApp();
await persister.startAutoSave();
let _persistSeq = 0;
store.addTablesListener(async () => {
    await persister.save();
    document.body.setAttribute('data-persist-seq', String(++_persistSeq));
});
document.body.setAttribute('data-app-ready', '1');
startSync();

Visual basics

I want a dark theme — phones are mostly dark these days and the app spends its time in workshop and storeroom lighting — and I want every later chapter that needs the muted grey for a label or the accent orange for a button to reach for the same names. So I pin the palette to CSS custom properties once, here, and the feature chapters can use var(--muted) without me having to remember which shade I picked.

:root{
    --bg:#1b1d2e; --card:#262a40; --fg:#e8e8f0; --muted:#8a8ea5;
    --accent:#f9a826;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,sans-serif;line-height:1.4;min-height:100vh}
[hidden]{display:none !important}

Open the app

The first time I open the app on a fresh phone there’s nothing in the store yet, and an empty page would leave me wondering whether the thing failed to load. So I want a small landing message: an emoji big enough to read across the room, the app’s name, a single line saying “no boxes yet.” It should disappear the second I add anything, no matter what — a box or just an unassigned item.

The test I want to pin first is the trivial one: open the app, see the hint. (clear_state, used here and in every later test, resets IndexedDB and localStorage between cases — defined in Playwright tests.)

@testcase
def test_empty_state(page):
    """No box recorded yet → the empty-state line is visible."""
    clear_state(page)
    assert page.get_by_text("No boxes yet").is_visible(), \
        "empty-state line not visible after fresh load"
    print("  PASS: empty state")

“Disappear the second I add anything” tells me what to compute: whether both tables are empty. Because the renderer runs on every store change, I don’t need any kind of show/hide gymnastics — the template returns the hint when both tables are empty, and returns nothing the next time the user has actually written something. The empty state lives in the document the same way a paragraph does when it has content to display, and is simply absent otherwise.

function emptyStateTemplate(){
    const empty = !Object.keys(store.getTable('boxes')).length
               && !Object.keys(store.getTable('items')).length;
    if(!empty) return '';
    return html`
      <<empty-state-markup>>
    `;
}

The wording of the hint matters: the test reaches for the literal string “No boxes yet”, so if I change the copy later I’ll have to update the test in the same edit.

<div class="empty-state">
  <div class="emoji">&#x1f4e6;</div>
  <h1>Organiser</h1>
  <p>No boxes yet.</p>
</div>

The thing I most want to avoid visually is the user mistaking the hint for a stuck loading screen. Centering the block, muting the text colour, and pushing the emoji to font-size 3 buys that — at a glance it reads “this is what the app looks like when it’s empty”, not “the thing is still spinning.”

.empty-state{text-align:center;padding:60px 20px;color:var(--muted)}
.empty-state .emoji{font-size:3rem;margin-bottom:12px}
.empty-state h1{font-size:1.4rem;margin:0 0 8px 0;color:var(--fg)}
.empty-state p{font-size:.95rem;line-height:1.5}

Add a box

This is the feature the whole app is in service of. The single thing I’ll remember about a box is what it looks like, and that’s literally what the camera in my pocket gives me. So recording a box is: tap a button, pick (or take) a photo, save. No name to type, no colour to fiddle with, no field that doesn’t earn its place — the photo is the box, from the moment it lands.

The test pins down what “added” feels like end to end: the thumbnail appears in the list, the empty-state hint goes away.

@testcase
def test_add_first_box(page):
    """Adding a first box puts its photo thumbnail in the list and
    dissolves the empty-state hint."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    assert page.get_by_role("button", name="Box workshop").is_visible(), \
        "new box thumbnail not listed"
    assert not page.get_by_text("No boxes yet").is_visible(), \
        "empty-state still visible after add"
    print("  PASS: add first box")

The entry point lives in the top bar. I want the bar to host more than just this button later — at minimum a search field, and eventually a sync indicator — so the action takes its place alongside the others rather than dominating the screen.

<button type="button" @click=${openBoxForm}>Add a box</button>

Now that the bar has its first occupant, it earns its styling. A horizontal flex row with the actions pushed to the right end is what I want — the status indicator and the search field are what will sit on the left and centre once they arrive. Whatever button lands in the bar wears the accent orange so it stands out as a primary action.

.app-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #2a2d44}
.app-bar button{background:var(--accent);color:#111;padding:8px 14px;border:0;border-radius:6px;font:inherit;font-weight:600;cursor:pointer}
.app-bar-actions{display:flex;gap:8px;margin-left:auto}

Tapping the button opens a form, and I want every later form (the item form is the next one) to feel identical: same width, same spacing, same submit/cancel pair at the bottom. I codify that once now — under the add-form class — so each later chapter just inherits the look by carrying the same class.

.add-form{display:grid;gap:10px;padding:16px;max-width:420px;margin:0 auto}
.add-form label{display:block;font-size:.9rem;color:var(--muted)}
.add-form input[type=text]{width:100%;background:var(--card);color:var(--fg);border:1px solid #333;border-radius:6px;padding:8px;font:inherit;margin-top:4px}
.add-form input[type=color]{width:48px;height:32px;border:0;background:transparent;margin-top:4px;padding:0;cursor:pointer}
.add-form-actions{display:flex;gap:8px}
.add-form-actions button{flex:1;padding:10px;border:0;border-radius:6px;font:inherit;font-weight:600;cursor:pointer}
.add-form-actions button[type=submit]{background:var(--accent);color:#111}
.add-form-actions button[type=button]{background:var(--card);color:var(--fg)}

Same goes for the list of boxes I’m about to create. I want the list itself to flow — boxes arranged left-to-right and wrapping to a new row when they run out of width, the way a contact-sheet of photos would. One-box-per-line wastes the horizontal space on anything wider than a phone, and even on a phone two compact cards fit comfortably side by side. Each card has a flex basis of 160 pixels so cards stretch a little to fill an even row, and the list itself caps at 840 pixels so it stays centred and readable on a wide screen.

One small thing the rule does explicitly that looks redundant is set flex-direction:row. The list also wears the .reorder-list class so the shared reorder engine picks it up, and that engine sets flex-direction:column on its lists by default (a sensible default for the column-shaped lists most callers want to reorder). I want a row layout instead, so I state it. Same with placing this rule after the shared reorder CSS in the export, so the cascade resolves my way.

Inside each card, a 48×48 thumbnail carries the photo. The earlier version cropped to fill the square (object-fit:cover), which threw away the edges of every non-square photo — useless if the bit that identifies the box is at the top of the shot. I use object-fit:contain instead: the whole photo is visible inside the square, letterboxed when the aspect ratio doesn’t match. To make the letterboxing read as deliberate rather than as a glitch, I give the button a background that matches the page, not the card. The same contain rule applies to the bigger preview <img> inside the edit form, where the photo gets to fill the form’s width for a proper look before saving. The item form reuses that preview, which is why both rules live here rather than in Items with photos.

.boxes-list{list-style:none;padding:0 16px;margin:0 auto;max-width:840px;display:flex;flex-direction:row;flex-wrap:wrap;gap:8px}
.box{padding:8px 14px;background:var(--card);border-radius:8px;flex:1 1 160px}
.box-photo{
    appearance:none;border:1px solid #00000033;padding:0;
    width:48px;height:48px;border-radius:8px;
    overflow:hidden;flex-shrink:0;cursor:pointer;
    background:var(--bg);
}
.box-photo-thumb{display:block;width:100%;height:100%;object-fit:contain}
.item-image-preview,.box-photo-preview{display:block;width:100%;max-height:60vh;border-radius:6px;margin-top:4px;object-fit:contain}

Now the form itself. The story of the form is: I pick a file, I see what I picked, I save. So the markup needs a file input, a preview that’s only there once a photo has actually landed, and the submit/cancel pair. The same form will serve the edit case later (cf. Edit a box), at which point a Delete button slots into the action row — but that arrives with its own chapter, so for now there’s an opening for it and nothing else.

The preview’s alt text deserves a word. I want the test to wait on something deterministic when a new photo lands, not a guess-the-delay sleep. The cheapest signal I can hand it is the alt text itself, set on the same render that puts the preview on screen — so I embed the file’s basename in there. The edit test in Edit a box is what really exercises this, but the contract is established here.

<form class="add-form" @submit=${onBoxFormSubmit}>
  <label>Photo
    <input type="file" accept="image/*" capture="environment"
           @change=${onBoxPhotoChange}>
  </label>
  ${photo ? html`
    <img class="box-photo-preview"
         src=${photo}
         alt=${photoName ? `Photo preview ${photoName}` : 'Photo preview'}>
  ` : ''}
  <div class="add-form-actions">
    <button type="submit">Save</button>
    <button type="button" @click=${closeForm}>Cancel</button>
    <<box-form-action-extras>>
  </div>
</form>

The form sits in front of the rest of the page when it’s open and is otherwise absent. It opens centred in the viewport — a stable spot the eye always lands on, that survives a phone rotation without any geometry to recompute — and a modal backdrop holds the rest of the page until I commit one way or the other. One piece of ephemeral state drives it: ui.formOpen names which form is showing, and the renderer picks the right template. The same flag will gate the item form too (cf. Add an item), which is what guarantees only one form is ever on screen.

modalShellTemplate wraps any form in the backdrop + the shell. The backdrop has no click handler — there’s no escape route except Save or Cancel — and the shell sits one z-index above it. No inline geometry: the centring is pure CSS, which re-resolves automatically on a viewport change.

function modalShellTemplate(form){
    return html`
      <div class="modal-backdrop"></div>
      <div class="modal-shell">
        ${form}
      </div>
    `;
}

The centring, backdrop, sizing and overflow live in the shared modal-shell block — same pattern any future modal in this app would want. All this chapter has to add locally is the reset that lets the form’s grid sit flush against the shell’s rounded edges.

.modal-shell .add-form{margin:0}

Opening the form is a single state mutation: seed an empty draft, flip the flag, and stash the click target’s rect so the shell can place itself next to it. Closing it does the reverse and wipes both form drafts and the anchor, since I don’t want yesterday’s half-typed item name to reappear when I next open that form either. closeForm lives here because it’s symmetrical with openBoxForm, but Add an item and the delete buttons share it.

function openBoxForm(){
    setUI({
        formOpen: 'box',
        boxForm: {photo: '', photoName: '', editingId: ''},
    });
}

function closeForm(){
    setUI({
        formOpen: null,
        boxForm: {photo: '', photoName: '', editingId: ''},
        itemForm: {name: '', image: '', bgColor: '', editingId: '', nameError: false},
    });
}

The picked file is the next problem. A modern phone camera hands me a JPEG of several megapixels — more than enough to balloon IndexedDB after a few dozen boxes, and obscene for what’s going to be a 48-pixel thumbnail. So I downscale before storing. I do it through a <canvas> because that’s the one place in the browser where I can decode an image, resize it, and re-encode it without leaving the page. 200 pixels on the long side is enough for the thumbnail and for me to still recognise the box; JPEG 0.85 keeps the file small without artefacts at this size. The function lives at module scope because the item form (cf. Items with photos) will reuse exactly the same pipeline.

async function fileToThumbnail(file){
    const url = URL.createObjectURL(file);
    try {
        const img = await new Promise((res, rej) => {
            const i = new Image();
            i.onload = () => res(i);
            i.onerror = rej;
            i.src = url;
        });
        const max = 200;
        const scale = Math.min(max / img.width, max / img.height, 1);
        const canvas = document.createElement('canvas');
        canvas.width = Math.max(1, Math.round(img.width * scale));
        canvas.height = Math.max(1, Math.round(img.height * scale));
        canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
        return canvas.toDataURL('image/jpeg', 0.85);
    } finally {
        URL.revokeObjectURL(url);
    }
}

When the file input changes, I run it through the pipeline and put the result into ui.boxForm. There is no separate show/hide toggle for the preview — the form template re-renders with the new state, and the conditional in the markup is what flips it on. If the user clears the input, the same state mutation drops the preview by the same path.

async function onBoxPhotoChange(e){
    const file = e.target.files[0];
    if(!file){
        setUI({boxForm: {...ui.boxForm, photo: '', photoName: ''}});
        return;
    }
    const photo = await fileToThumbnail(file);
    const photoName = file.name.replace(/\.[^.]+$/, '');
    setUI({boxForm: {...ui.boxForm, photo, photoName}});
}

Save is the moment the draft becomes a real row. A brand-new box needs to land at the end of the list so the user sees it appear where they expect, which means giving it an order one past the current maximum; an edit, on the other hand, must keep the existing order or the box would jump around mid-edit. The editingId bit of the draft is what tells the two cases apart.

function onBoxFormSubmit(e){
    e.preventDefault();
    const {photo, photoName, editingId} = ui.boxForm;
    if(!photo) return;
    const id = editingId || crypto.randomUUID();
    const existing = store.getRow('boxes', id);
    const orders = Object.values(store.getTable('boxes')).map(r => r.order ?? 0);
    const order = existing?.order ?? (orders.length ? Math.max(...orders) + 1 : 0);
    store.setRow('boxes', id, {photo, photoName, order});
    closeForm();
}

Once boxes exist, they need to appear in a list. The list is ordered by the order cell — not by table iteration, which is what makes Reorder boxes possible later. I also bake in the search-filter branch right now even though Find a box by searching for an item is what populates ui.searchQuery later; until that chapter the query is empty (the initial UI state seeds it that way) and the filter is a no-op. Putting it here saves me from having to revisit this template once searching arrives.

function boxesListTemplate(){
    const rows = store.getTable('boxes');
    const itemsById = store.getTable('items');
    const queryB = ui.searchQuery.trim().toLowerCase();
    const itemMatchesB = name => !queryB || name.toLowerCase().includes(queryB);
    const sortedIds = Object.keys(rows).sort((a, b) =>
        (rows[a].order ?? 0) - (rows[b].order ?? 0));
    const ids = sortedIds.filter(id => {
        if(!queryB) return true;
        return Object.keys(itemsById).some(iid =>
            itemsById[iid].boxId === id && itemMatchesB(itemsById[iid].name));
    });
    if(!ids.length) return '';
    return html`
      <<boxes-list-markup>>
    `;
}

<ul class="boxes-list reorder-list" data-reorder="boxes" aria-label="Boxes">
  ${ids.map((id, idx) =>
      boxTemplate(id, idx, rows[id], itemsById, itemMatchesB))}
</ul>

Each card in the list is, again, just the photo — wrapped in a <button> because tapping it does something (it opens the edit form, as Edit a box sets up). The button carries the accessible label Box <photoName> so the same word is what a screen reader hears, what the test reaches for, and what the edit-click reads.

Three later chapters extend this card: Drop an item into a box needs to mark the <li> as a drop target, Add an item needs to add a chip row inside it for the contained items, and Reorder boxes needs to add a grip and a data-idx. I open four slots now — three for additions to the body, one for additions to the <li>’s attributes — and let each chapter pour into the slot it owns.

<li
  class="box reorder-item"
  data-box-id=${id}
  <<render-box-attrs>>
>
  <<render-box-base>>
  <<render-box-items>>
  <<render-box-grip>>
</li>

The first slot is mine to fill: the photo button itself, with the click that opens the edit form. openBoxEdit is defined over in Edit a box, but the gesture lives on the photo button, and I want the handler attached to the element that owns the gesture rather than fished out by event delegation somewhere far away.

<button type="button" class="box-photo"
        aria-label="Box ${row.photoName}"
        @click=${() => openBoxEdit(id, row)}>
  <img class="box-photo-thumb" src=${row.photo} alt="">
</button>

Edit a box

Sooner or later I’ll take a bad photo, or the box’s contents will drift enough that the current photo no longer represents it. I want the fix to be cheap: tap the box, retake the photo, save. No separate edit screen. The form I already built for adding is the natural place — it has the same single field (a photo), and the difference between adding and editing is just “do I have an id to overwrite, or do I mint a new one.” That’s already the shape of the editingId switch in the submit handler.

The test does add → tap → re-photo → save, and asserts that the old thumbnail is gone (otherwise the edit would have created a duplicate row instead of overwriting). The wait on Photo preview garage rather than a plain “Photo preview” is on purpose: the thumbnail pipeline is async, so the only way the preview’s alt text reads Photo preview garage is after the new photo has landed in ui.boxForm.photoName — which is precisely the state Save needs to read. Waiting on the alt text is therefore the same as waiting on the state.

@testcase
def test_edit_box(page):
    """Tapping a box opens the form; uploading a new photo overwrites it."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview workshop").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Box workshop").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "garage.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview garage").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    assert not page.get_by_role("button", name="Box workshop").is_visible(), \
        "old thumbnail still visible — edit may have created a duplicate"
    assert page.get_by_role("button", name="Box garage").is_visible()
    print("  PASS: edit box")

A modal backdrop holds the rest of the page until I commit — clicking another box behind the form does nothing.

@testcase
def test_edit_form_is_modal(page):
    """The modal backdrop blocks clicks on items behind the form
    — only Save or Cancel can close it."""
    clear_state(page)
    for name in ["alpha", "bravo"]:
        page.get_by_role("button", name="Add a box").click()
        page.get_by_label("Photo").set_input_files(files=[{
            "name": f"{name}.png", "mimeType": "image/png", "buffer": MINIMAL_PNG}])
        page.get_by_role("img", name="Photo preview").wait_for(state="visible")
        page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Box alpha").click()
    page.get_by_role("img", name="Photo preview alpha").wait_for(state="visible")
    bravo_rect = page.get_by_role("button", name="Box bravo").bounding_box()
    page.mouse.click(
        bravo_rect["x"] + bravo_rect["width"] / 2,
        bravo_rect["y"] + bravo_rect["height"] / 2,
    )
    assert page.get_by_role("img", name="Photo preview alpha").is_visible(), \
        "form switched source after a click on another box — backdrop didn't block"
    print("  PASS: edit form is modal")

The form sits centred in the viewport — both axes, regardless of where on the page the user tapped. A modal that jumps next to the click target was a clever idea that played badly in practice: it left the form pinned to the corner the photo happened to occupy, made the rotation case (the viewport shifts but the placement stays frozen at the old coordinates) a constant source of off-screen forms, and gave the eye no stable place to look. Centred in the viewport, the form is always where the user expects it to be, and a phone rotation redraws into the new shape with nothing further to do.

@testcase
def test_edit_form_centered_in_viewport(page):
    """The edit form is centred in the viewport, before and after rotation."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview workshop").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Box workshop").click()
    page.locator("form.add-form").wait_for(state="visible")
    def assert_centered(vw, vh, slack=2):
        rect = page.locator(".modal-shell").bounding_box()
        cx = rect["x"] + rect["width"] / 2
        cy = rect["y"] + rect["height"] / 2
        assert abs(cx - vw / 2) < slack, \
            f"shell not horizontally centred: cx={cx}, vp_w={vw}"
        assert abs(cy - vh / 2) < slack, \
            f"shell not vertically centred: cy={cy}, vp_h={vh}"
        assert rect["x"] >= 0 and rect["x"] + rect["width"] <= vw, \
            f"shell off-screen horizontally: {rect}"
        assert rect["y"] >= 0 and rect["y"] + rect["height"] <= vh, \
            f"shell off-screen vertically: {rect}"
    assert_centered(*PHONE_VIEWPORT.values())
    try:
        page.set_viewport_size({"width": 800, "height": 400})
        page.wait_for_function(
            """() => {
              const s = document.querySelector('.modal-shell');
              if (!s) return false;
              const r = s.getBoundingClientRect();
              return Math.abs((r.left + r.width / 2)  - window.innerWidth / 2) < 2
                  && Math.abs((r.top  + r.height / 2) - window.innerHeight / 2) < 2;
            }""",
            timeout=1500,
        )
        assert_centered(800, 400)
    finally:
        page.set_viewport_size(PHONE_VIEWPORT)
    print("  PASS: edit form centered in viewport")

All this chapter has to add is the gesture: tap a box, open the form pre-filled with that box’s state. editingId tells the submit handler to overwrite rather than mint. The form opens centred in the viewport, same as the add case — there’s no separate edit screen and no per-box positioning to compute.

function openBoxEdit(id, row){
    setUI({
        formOpen: 'box',
        boxForm: {
            photo: row.photo || '',
            photoName: row.photoName || '',
            editingId: id,
        },
    });
}

Delete a box

At some point a box stops being useful — the shelf it lived on is gone, the things it held are scattered. I want to be able to drop it from the inventory without it being so easy that an accidental tap costs me a row. So: a Delete button on the edit form (not anywhere on the box card itself, where I might brush it by mistake), and a browser confirm() behind it. The test does the simple case — add, edit, delete, gone — and the harder case (what happens to items inside a deleted box) waits for Drop an item into a box, because until then no item can be inside a box.

@testcase
def test_delete_box(page):
    """Deleting an empty box removes its thumbnail from the list."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Box workshop").click()
    page.once("dialog", lambda d: d.accept())
    page.get_by_role("button", name="Delete").click()
    assert not page.get_by_role("button", name="Box workshop").is_visible(), \
        "thumbnail still listed"
    print("  PASS: delete box")

The button slots into the action row I already left open in the form template (Add a box’s box-form-action-extras). It appears only when the form is in edit mode — i.e. when ui.boxForm.editingId is set — because in add mode there is literally no row to delete.

${ui.boxForm.editingId ? html`
  <button type="button" class="danger" @click=${onBoxFormDelete}>Delete</button>
` : ''}

This is also the first destructive button in the app, so it earns the danger colour. Delete an item later wears the same class.

.add-form-actions button.danger{background:#e94560;color:#fff}

The handler itself does one thing that isn’t obvious: before removing the box’s row it sweeps every item that points at this box and flips its boxId back to empty, so the items return to Unassigned rather than becoming orphans pointing at a row that no longer exists. The order matters — items first, then the box — because the renderer fires once at the end of the transaction and I don’t want it ever seeing the intermediate state. The confirm message reflects this so the user knows their items aren’t going to disappear with the box.

function onBoxFormDelete(){
    const {editingId} = ui.boxForm;
    if(!editingId) return;
    if(!confirm('Delete this box? Items inside go back to Unassigned.')) return;
    const items = store.getTable('items');
    for(const id of Object.keys(items)){
        if(items[id].boxId === editingId){
            store.setCell('items', id, 'boxId', '');
        }
    }
    store.delRow('boxes', editingId);
    closeForm();
}

Reorder boxes

Once I have more than three boxes, creation order stops matching the physical layout in my workshop. I want my “shelf above the workbench” box near the top because that’s where I look first, and I want to be able to put it there by hand. A drag-to-reorder gesture with a small grip on the right edge of each card is the obvious affordance: it tells me the row is movable without shouting at me, and it keeps the bulk of the card free for tap gestures.

Two design choices I want to commit to before the code lands.

First, the order has to live in the store, not in the table’s iteration sequence. TinyBase iteration is stable across reloads, but it’s not stable across an edit or a delete-and-recreate. If I rely on it, the order will randomise itself behind my back the first time I edit a box. So every box gets an explicit order integer and the renderer sorts by it.

Second, the reorder gesture rewrites every box’s order cell in one pass — index 0, 1, 2, … all the way through — because keeping the integers dense is cheaper to reason about than juggling fractions or sparse indices. I wrap the whole rewrite in a store.transaction so the listener fires once at the end rather than once per cell, which matters during the drag because the pointer-engine’s geometry queries would otherwise be chasing a list that’s re-rendering under its feet.

I’m not writing the drag engine itself in this doc — it lives in shared blocks because Condorcet needs the same thing. The contract it expects is: a .reorder-list on the <ul>, a .reorder-item class plus a data-idx attribute on each <li>, and a .reorder-grip element to grab. When a drag finishes the engine fires a reorder:move event with from and to indices. My job is to listen for it and rewrite the order cells.

@testcase
def test_reorder_boxes(page):
    """Dragging a box's grip up changes its position in the list."""
    clear_state(page)
    for name in ["alpha", "bravo", "charlie"]:
        page.get_by_role("button", name="Add a box").click()
        page.get_by_label("Photo").set_input_files(files=[{
            "name": f"{name}.png",
            "mimeType": "image/png",
            "buffer": MINIMAL_PNG,
        }])
        page.get_by_role("img", name="Photo preview").wait_for(state="visible")
        page.get_by_role("button", name="Save").click()
    enable_drag(page)
    page.get_by_role("button", name="Reorder charlie").drag_to(
        page.get_by_role("button", name="Reorder alpha"))
    first_label = page.get_by_role(
        "button", name=re.compile(r"^Box ")).first.get_attribute("aria-label")
    assert first_label == "Box charlie", \
        f"reorder didn't take effect: first is {first_label!r}"
    print("  PASS: reorder boxes")

The floating clone the engine shows during the drag has the same outer dimensions as the box being dragged — no visible size jump when the gesture begins.

@testcase
def test_reorder_drag_clone_matches_source(page):
    """The floating drag clone matches the dragged box's
    width and height (no scale-up, no padding mismatch)."""
    clear_state(page)
    for name in ["alpha", "bravo"]:
        page.get_by_role("button", name="Add a box").click()
        page.get_by_label("Photo").set_input_files(files=[{
            "name": f"{name}.png", "mimeType": "image/png",
            "buffer": MINIMAL_PNG}])
        page.get_by_role("img", name="Photo preview").wait_for(state="visible")
        page.get_by_role("button", name="Save").click()
    enable_drag(page)
    src = page.locator("li.box").nth(0)
    src_rect = src.bounding_box()
    grip = page.get_by_role("button", name="Reorder alpha")
    grip_rect = grip.bounding_box()
    cx = grip_rect["x"] + grip_rect["width"] / 2
    cy = grip_rect["y"] + grip_rect["height"] / 2
    page.mouse.move(cx, cy)
    page.mouse.down()
    page.mouse.move(cx + 5, cy + 5)
    clone_rect = page.locator(".drag-clone").bounding_box()
    page.mouse.up()
    wd = abs(clone_rect["width"] - src_rect["width"])
    hd = abs(clone_rect["height"] - src_rect["height"])
    assert wd < 4, \
        f"clone width {clone_rect['width']} != source {src_rect['width']}"
    assert hd < 4, \
        f"clone height {clone_rect['height']} != source {src_rect['height']}"
    print("  PASS: reorder drag clone matches source")

document.addEventListener('reorder:move', e => {
    if(e.target.dataset?.reorder !== 'boxes') return;
    const { from, to } = e.detail;
    const rows = store.getTable('boxes');
    const ordered = Object.keys(rows).sort((a, b) =>
        (rows[a].order ?? 0) - (rows[b].order ?? 0));
    const [moved] = ordered.splice(from, 1);
    ordered.splice(to, 0, moved);
    store.transaction(() => {
        ordered.forEach((id, idx) => store.setCell('boxes', id, 'order', idx));
    });
});

Two contributions to the box card complete the engine’s contract.

The grip itself goes into Add a box’s render-box-grip slot — six dots on the right edge, recognisable as “grab me.” Its aria-label is Reorder <photoName>, which lets the test grab the right handle by name rather than by position, and lets a screen reader name it usefully too.

${ui.dragEnabled ? html`
  <button type="button" class="reorder-grip" aria-label="Reorder ${row.photoName}">
    <svg width="10" height="16" viewBox="0 0 10 16" aria-hidden="true">
      <circle cx="2" cy="3" r="1.3"/><circle cx="8" cy="3" r="1.3"/>
      <circle cx="2" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/>
      <circle cx="2" cy="13" r="1.3"/><circle cx="8" cy="13" r="1.3"/>
    </svg>
  </button>
` : ''}

The data-idx the engine reads belongs on the <li> itself, not on the grip, so it goes into the attributes slot rather than the body slot.

data-idx=${idx}

The grip’s styling lives here too — small and muted by default, brightening on hover — because the feature that owns the gesture is also the feature that owns its visual weight. The opacity ramp-on-hover is what tells the user the grip is interactive without it dominating the card when they’re not interacting.

.reorder-grip{
    appearance:none;border:0;padding:0;
    width:28px;height:28px;
    margin-right:-6px;
    background:transparent;color:var(--muted);
    opacity:.55;border-radius:6px;
    transition:opacity .15s, background-color .15s;
}
.reorder-grip:hover{opacity:1;background:rgba(255,255,255,.06)}
.reorder-grip svg{fill:currentColor;display:block;margin:auto;pointer-events:none}

One more visual the gesture owns: the floating clone shown during the drag. The shared engine builds it as a positioned <div class“drag-clone”>= and the engine’s default CSS paints it in the accent colour and scales it up 4% — visually distinct, but a poor match for a box card. The override below makes the clone wear the box’s own padding, background and border-radius, and drops the scale so the box doesn’t visibly jump on grab. :not(.item) keeps the rule off the chip-drag clone (cf. Drop an item into a box), where the engine’s default look is fine.

.drag-clone:not(.item){
    padding:8px 14px;
    background:var(--card);
    color:var(--fg);
    border-radius:8px;
    transform:none;
}

Add an item

Boxes are scaffolding. Items are the point — every screw, charger, allen key I want to find again next month. Two things drive the shape of this feature.

First, I almost never know which box a thing belongs in at the moment I record it. I find a loose USB cable on my desk; I want to add it to the inventory now and decide its box later, when I’m next standing in front of the shelves. So the item starts unassigned, and adding it requires no decision about placement. The boxId cell starts empty and stays empty until I drag the chip onto a box (cf. Drop an item into a box).

Second, unassigned items can’t be invisible — if they were, I’d forget I had them. I gather them in their own Unassigned section at the bottom of the page where they’re impossible to miss.

@testcase
def test_add_first_item(page):
    """Adding an item lands it in the Unassigned section."""
    clear_state(page)
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("3mm screw")
    page.get_by_role("button", name="Save").click()
    assert page.get_by_text("3mm screw").is_visible(), \
        "new item not in Unassigned"
    assert page.get_by_role("heading", name="Unassigned").is_visible(), \
        "Unassigned section header not visible"
    print("  PASS: add first item")

The form

Same entry pattern as the box form: a second button in the app-bar’s actions row, next to the existing one.

<button type="button" @click=${() => openItemForm()}>Add an item</button>

The form itself reuses everything I built for the box form — same add-form class, same ui.formOpen flag, same upload pipeline. What’s different: there’s a name field, the image is optional, and there’s a duplicate-name guard (Unique item names). The draft sits in ui.itemForm.

Three slots in the markup left for later chapters to fill: item-form-name-extras just below the name field (where Unique item names pours the duplicate-name alert), item-form-color between the image preview and the action row (where Items with background colour pours its picker and recent-colour swatches), and item-form-action-extras alongside Save and Cancel (where Delete an item pours its Delete button when the form is in edit mode).

<form class="add-form" @submit=${onItemFormSubmit}>
  <label>Item name
    <input type="text" placeholder="e.g. 3mm screw" required
           .value=${name}
           @input=${onItemNameInput}>
  </label>
  <<item-form-name-extras>>
  <label>Image
    <input type="file" accept="image/*" capture="environment"
           @change=${onItemImageChange}>
  </label>
  ${image ? html`
    <img class="item-image-preview" src=${image} alt="Image preview">
  ` : ''}
  <<item-form-color>>
  <div class="add-form-actions">
    <button type="submit">Save</button>
    <button type="button" @click=${closeForm}>Cancel</button>
    <<item-form-action-extras>>
  </div>
</form>

Opening this form follows the same pattern as the box form — seed an empty draft, flip ui.formOpen — and closeForm (in Add a box) already handles both. Edit an item will be the variant that pre-fills the draft instead of clearing it.

function openItemForm(opts = {}){
    setUI({
        formOpen: 'item',
        itemForm: {name: '', image: '', bgColor: '', editingId: '', nameError: false,
                   boxId: opts.boxId || ''},
        contextMenu: null,
    });
}

Submit is going to need to refuse a duplicate name, so the check is what I write first. The full reasoning for why duplicates are forbidden lives in Unique item names; for now the bit that matters is that the comparison is case-insensitive and trimmed, and that an item being edited has to be allowed to “duplicate” itself — otherwise I couldn’t even change its capitalisation.

function itemNameExists(name, exceptId){
    const items = store.getTable('items');
    const lower = name.toLowerCase();
    return Object.entries(items).some(([id, item]) =>
        id !== exceptId && item.name.toLowerCase() === lower);
}

Two input handlers feed the draft. The name field is bound two-way — what I type lands in ui.itemForm.name immediately — and the same keystroke also clears the duplicate-name alert so the next submit attempt starts fresh.

function onItemNameInput(e){
    setUI({itemForm: {...ui.itemForm, name: e.target.value, nameError: false}});
}

The image input runs through the same fileToThumbnail I wrote for the box photos — no point duplicating that pipeline — and writes the result into ui.itemForm.image.

async function onItemImageChange(e){
    const file = e.target.files[0];
    if(!file){
        setUI({itemForm: {...ui.itemForm, image: ''}});
        return;
    }
    const image = await fileToThumbnail(file);
    setUI({itemForm: {...ui.itemForm, image}});
}

Save commits the draft. Two cases I want to keep apart in my head: a new item must start unassigned (boxId empty), while an edit must preserve whatever box the item was already in — I don’t want renaming a screw to yank it out of its drawer. If the name’s a duplicate, the form stays open and the alert lights up.

function onItemFormSubmit(e){
    e.preventDefault();
    const {name: raw, image, bgColor, editingId, boxId} = ui.itemForm;
    const name = raw.trim();
    if(!name) return;
    if(itemNameExists(name, editingId)){
        setUI({itemForm: {...ui.itemForm, nameError: true}});
        return;
    }
    if(editingId){
        const existing = store.getRow('items', editingId);
        store.setRow('items', editingId, {
            name, image, bgColor, boxId: existing?.boxId || '',
        });
    } else {
        store.setRow('items', crypto.randomUUID(), {
            name, image, bgColor, boxId: boxId || '',
        });
    }
    closeForm();
}

The chip

Now the chip the item renders as. It has to work in two places — on its own in the Unassigned section, and crammed into a box card alongside its siblings — but I want it to be the same template in both, so that any future change (a delete X, a long-press menu) lands in one place. The chip itself is a tap target for editing (Edit an item defines what that means), and it carries data-draggable so the drag engine picks it up as a source later.

<li class="item" data-item-id=${id} ?data-draggable=${ui.dragEnabled}
    style=${item.bgColor ? `background:${item.bgColor};color:${readableForeground(item.bgColor)}` : ''}
    @click=${() => openItemEdit(id, item)}>
  ${item.image ? html`<img class="item-thumb" src=${item.image} alt=${item.name}>` : ''}
  <span class="item-name">${item.name}</span>
</li>

The Unassigned section

The Unassigned section holds the unboxed chips at the bottom of the page. It only appears when at least one item is unassigned (an empty inventory shouldn’t waste vertical space on an empty heading), and it carries data-drop-target itself so that I can later drag an item back out of a box and into Unassigned — the same drop engine that handles dragging into a box handles dragging out.

This is also the only place this layout appears, so the styling for it earns its place here. A small uppercased heading reads as a section header rather than as content, and the chips stack vertically as full-width pills.

.unassigned{padding:0 16px;margin:8px auto 0;max-width:420px}
.unassigned h2{font-size:.85rem;color:var(--muted);font-weight:600;margin:8px 0;text-transform:uppercase;letter-spacing:.05em}
.items-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px}
.items-list .item{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--card);border-radius:8px;color:var(--fg)}

The ids that survive the filter are sorted alphabetically by name before they hit the template. The store doesn’t preserve a meaningful order on its own — insertion order is whatever I happened to type — and seeing the same item land in a different slot every time I open the app would be its own small kind of “where is it?” Sorting by name gives me a stable index.

function unassignedSectionTemplate(){
    const itemsTable = store.getTable('items');
    const queryU = ui.searchQuery.trim().toLowerCase();
    const unassignedIds = Object.keys(itemsTable)
        .filter(id => {
            if(itemsTable[id].boxId) return false;
            return !queryU || itemsTable[id].name.toLowerCase().includes(queryU);
        })
        .sort((a, b) => itemsTable[a].name.localeCompare(itemsTable[b].name));
    if(!unassignedIds.length) return '';
    return html`
      <<unassigned-section-markup>>
    `;
}

<section class="unassigned" ?data-drop-target=${ui.dragEnabled}>
  <h2>Unassigned</h2>
  <ul class="items-list">
    ${unassignedIds.map(id => itemTemplate(id, itemsTable[id]))}
  </ul>
</section>

Chips inside a box

Once an item lands in a box, the same chip appears again — but this time crammed in alongside its siblings inside a box card. I want it smaller and flowing horizontally rather than stacking, because a box might hold a dozen of them and I don’t want the card growing taller for each one. So I override the Unassigned styling whenever a chip is rendered inside the boxes-list: same element, two contexts, two looks.

.items-in-box{
    flex:1;
    list-style:none;margin:0;padding:0;
    display:flex;flex-wrap:wrap;gap:4px;
}
.items-in-box .item{display:flex;align-items:center;gap:6px;padding:6px 10px;background:var(--bg);border-radius:6px;color:var(--fg);font-size:.9rem}

And the slot that puts the chips there — the render-box-items slot the box card (Add a box) left open for me. The itemMatches callback the box loop passes in is what makes search work later — it filters out the chips that don’t survive the current query, so a search for “screw” shows only screws even inside a box that also holds other things. The chips are sorted by name for the same reason the unassigned ones are: I want the same item to be in the same spot every time I look into the box.

<ul class="items-in-box">
  ${Object.keys(itemsById)
    .filter(iid => itemsById[iid].boxId === id && itemMatches(itemsById[iid].name))
    .sort((a, b) => itemsById[a].name.localeCompare(itemsById[b].name))
    .map(iid => itemTemplate(iid, itemsById[iid]))}
</ul>

The test for this drops two items into a box in reverse alphabetical order and asserts they render the right way round.

@testcase
def test_items_sorted_alphabetically_in_box(page):
    """Items inside a box appear in alphabetical order regardless
    of the order they were added."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    enable_drag(page)
    for item_name in ["Saw", "Hammer"]:
        page.get_by_role("button", name="Add an item").click()
        page.get_by_label("Item name").fill(item_name)
        page.get_by_role("button", name="Save").click()
        page.get_by_text(item_name).drag_to(
            page.get_by_role("button", name="Box workshop"))
    chips_in_box = page.locator(".boxes-list .items-in-box .item-name").all_inner_texts()
    assert chips_in_box == ["Hammer", "Saw"], \
        f"items not alphabetical in box: {chips_in_box}"
    print("  PASS: items sorted alphabetically in box")

Edit an item

Same gesture as for boxes — tap the chip, the form opens pre-filled, save overwrites. The mental model is “you tap a thing to change it” everywhere in the app; there’s no separate edit affordance to learn.

One subtlety the gesture would otherwise trip on: the chip is also the source of a drag-to-box gesture (cf. Drop an item into a box). The browser suppresses click when pointerdown and pointerup happen far enough apart, which is exactly the same threshold the drop engine uses to recognise a drag — so a real grab-and-drop never accidentally fires the edit click. The two gestures can share the element without a custom guard.

The fields the form lets me edit are name, image, and bgColor. Crucially not boxId — placement is set by dragging the chip into a box, not by typing into a field. So the submit handler is careful to read the existing row’s boxId and write it back unchanged. If I rename a screw, it stays in whatever drawer I put it in.

@testcase
def test_edit_item(page):
    """Tapping an item opens the form prefilled; saving overwrites the name."""
    clear_state(page)
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    page.get_by_text("Hammer").click()
    assert page.get_by_label("Item name").input_value() == "Hammer", \
        "form not prefilled with the item's current name"
    page.get_by_label("Item name").fill("Mallet")
    page.get_by_role("button", name="Save").click()
    assert not page.get_by_text("Hammer").is_visible(), \
        "old name still visible — edit may have created a duplicate"
    assert page.get_by_text("Mallet").is_visible()
    print("  PASS: edit item")

function openItemEdit(id, item){
    setUI({
        formOpen: 'item',
        itemForm: {
            name: item.name || '',
            image: item.image || '',
            bgColor: item.bgColor || '',
            editingId: id,
            nameError: false,
        },
    });
}

Delete an item

Mechanically identical to Delete a box: a danger-coloured button on the edit form, behind a confirm(). The only thing that’s simpler here is the cleanup — an item has no children to reparent, so it just goes. No transaction, no sweep.

${ui.itemForm.editingId ? html`
  <button type="button" class="danger" @click=${onItemFormDelete}>Delete</button>
` : ''}

@testcase
def test_delete_item(page):
    """Deleting an item removes it from the list."""
    clear_state(page)
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    page.get_by_text("Hammer").click()
    page.once("dialog", lambda d: d.accept())
    page.get_by_role("button", name="Delete").click()
    assert not page.get_by_text("Hammer").is_visible(), "item still listed"
    print("  PASS: delete item")

function onItemFormDelete(){
    const {editingId} = ui.itemForm;
    if(!editingId) return;
    if(!confirm('Delete this item?')) return;
    store.delRow('items', editingId);
    closeForm();
}

Items with photos

Standing in the workshop with a screw in my hand, I want to be able to glance at the chip and see “yes, that’s the one” — and for some items, the name doesn’t get me there fast enough. “3mm screw” looks like every other screw I own. But a tiny photo next to the name closes the gap immediately: I see the head, the thread, the rough size; I match it to what I’m holding.

This is the same image-upload story as for boxes, with one important difference: for an item, the photo is optional. Some things (“hammer”) really are findable by name alone, and forcing a photo for every USB cable would be tedious. So the chip renders the thumbnail when there is one, and just the name when there isn’t.

Nearly everything I need for this is already in place. The file input, the change handler, the fileToThumbnail pipeline, the preview, the conditional render of the chip’s <img> — all that was wired in Add an item. What this chapter actually adds is small: the test that exercises the pipeline end-to-end, and the CSS rule that sizes the chip thumbnail.

The test uses a 1×1 PNG as the fixture: small enough that I can embed it inline as hex in the test runner, real enough to ride the load-resize-store-render path all the way through. The rendered <img> carries alt={item.name} so the test can find it by accessible name, no inspection of the data-URL itself.

@testcase
def test_add_item_with_image(page):
    """An item saved with an image renders that image in its chip."""
    clear_state(page)
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_label("Image").set_input_files(files=[{
        "name": "hammer.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Image preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    assert page.get_by_role("img", name="Hammer").is_visible(), \
        "image not rendered for the item"
    print("  PASS: add item with image")

The thumbnail has to be small enough to fit in a chip without pushing the name off the line — 24 pixels is about the size of the surrounding text. Cropped to a square so all chips line up visually, even if my source photo was portrait.

.item-thumb{width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0}

Items with background colour

Photos work for the items that earn one, but I want a second axis to scan by — and one that costs nothing to apply. A solid background tint on a chip lets me paint everything electrical the same blue, every workshop fastener the same red, and the page reads like a colour-coded legend before I’ve focused on any single word.

Two things shape the feature. One: the colour is optional — most items don’t need one, and the chip should look unchanged until I deliberately tint it. Two: once I’ve used a colour, I want it back with a single tap. Pulling a precise hex out of the browser’s colour picker every time I add a screw would defeat the point; the second screw should match the first by recognition, not by retyping.

@testcase
def test_item_background_colour(page):
    """A chosen colour paints the chip, and the colour is offered
    as a one-tap shortcut on the next item."""
    clear_state(page)
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_label("Background colour").fill("#ff0000")
    page.get_by_role("button", name="Save").click()
    bg = page.get_by_text("Hammer").evaluate(
        "el => getComputedStyle(el.closest('li.item')).backgroundColor")
    assert bg == "rgb(255, 0, 0)", \
        f"chip not painted with the chosen colour: {bg}"
    page.get_by_role("button", name="Add an item").click()
    page.get_by_role("button", name="Use colour #ff0000").click()
    assert page.get_by_label("Background colour").input_value() == "#ff0000", \
        "shortcut didn't apply the colour to the picker"
    print("  PASS: item background colour")

A third concern lands on the chip itself. The dark theme’s near-white default foreground would disappear against any pale tint, and the eye’s outsized sensitivity to green means even a mid-bright green is light enough to drown out a near-white text on top. So the chip render picks a contrasting foreground from whatever I chose, and the rest of the app keeps treating its foregrounds as the theme says.

@testcase
def test_chip_text_adapts_to_tint(page):
    """The chip text picks a contrasting colour against
    whatever tint sits behind it — pale, mid-bright
    chromatic, or near-black."""
    clear_state(page)
    cases = [
        ("Snow", "#ffffff", "rgb(17, 17, 17)"),
        ("Cordelettes", "#22aa22", "rgb(17, 17, 17)"),
        ("Inkwell", "#111122", "rgb(255, 255, 255)"),
    ]
    for name, bg, expected_fg in cases:
        page.get_by_role("button", name="Add an item").click()
        page.get_by_label("Item name").fill(name)
        page.get_by_label("Background colour").fill(bg)
        page.get_by_role("button", name="Save").click()
        actual = page.get_by_text(name).evaluate(
            "el => getComputedStyle(el.closest('li.item')).color")
        assert actual == expected_fg, \
            f"{name} on {bg}: expected text {expected_fg}, got {actual}"
    print("  PASS: chip text adapts to tint")

The plumbing under the colour was wired ahead of time. The schema reserves a bgColor cell on every item row (Store schema), the submit handler in the form reads and writes it, Edit an item pre-fills it, and the chip render in the chip applies it as inline background and color styles — the latter computed by readableForeground below. What this chapter adds is the part the user touches: the picker, the swatch shortcuts, the readability rule, and the styling to match.

The WCAG relative-luminance formula does the work. Convert each sRGB channel out of gamma encoding, weight by the eye’s sensitivity (green carries the bulk of the luminance), and threshold at the crossover point where contrast against black equals contrast against white — about 0.179 on the 0…1 scale. Below that, a light foreground wins; above, a dark one. The two tones the rest of the app already uses — #111 and #fff — slot straight in.

function readableForeground(hex){
    const h = hex.replace('#', '');
    const channel = i => {
        const c = parseInt(h.substr(i, 2), 16) / 255;
        return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    };
    const L = 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
    return L > 0.179 ? '#111' : '#fff';
}

The shortcuts come straight from the store — every distinct bgColor cell currently in use, lowercased and deduplicated. I don’t bother caching this across renders: the items table is small, the set operation is cheap, and “what colours have I used so far” is exactly the live answer the user wants.

function usedItemColours(){
    const items = store.getTable('items');
    const seen = new Set();
    for(const id of Object.keys(items)){
        const c = (items[id].bgColor || '').toLowerCase();
        if(c) seen.add(c);
    }
    return [...seen].sort();
}

Both the picker’s @input and a swatch’s @click route the chosen colour through the same setter so the form state has a single ingress — and the “no colour” exit at the head of the swatch row routes through it too, with the empty string.

function setItemFormColour(value){
    setUI({itemForm: {...ui.itemForm, bgColor: value || ''}});
}

The markup pours into the slot the form reserved for it. The picker falls back to the browser’s neutral default when no colour has been chosen yet, so an unpainted item never shows a meaningful colour in the input until the user picks one.

<label>Background colour
  <input type="color" .value=${ui.itemForm.bgColor || '#000000'}
         @input=${e => setItemFormColour(e.target.value)}>
</label>
<div class="colour-swatches" role="group" aria-label="Recently used colours">
  <button type="button" class="colour-swatch colour-none"
          aria-label="No colour"
          @click=${() => setItemFormColour('')}></button>
  ${usedItemColours().map(c => html`
    <button type="button" class="colour-swatch"
            style=${`background:${c}`}
            aria-label="Use colour ${c}"
            @click=${() => setItemFormColour(c)}></button>
  `)}
</div>

The swatches are circles big enough to read as taps on a phone. The “no colour” head wears a diagonal slash so it doesn’t blur into another dark shade in the row.

.colour-swatches{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px}
.colour-swatch{
    width:28px;height:28px;border-radius:50%;
    border:2px solid #00000033;cursor:pointer;padding:0;
}
.colour-swatch.colour-none{
    background:transparent;border:2px solid var(--muted);
    background-image:linear-gradient(45deg,transparent 45%,var(--muted) 45%,var(--muted) 55%,transparent 55%);
}

Unique item names

An item’s name is its identity to me. It’s what I type when I’m looking for it later, and it’s the answer the app gives back when I find it (“the 3mm screws are in the workshop box”). If I accidentally let two items end up called “hammer”, then searching for “hammer” returns two answers, neither obviously preferable to the other, and the search becomes useless for that name. So the app needs to actively prevent duplicates rather than let me blunder into them.

My instinct is to be lenient about what counts as “the same name”: “hammer”, “Hammer”, and “Hammer " (with a trailing space I didn’t notice) should all collide. And the user-facing message has to actually appear — silently dropping the save would leave me staring at the form wondering why nothing happened.

There’s one edge case the comparison has to allow for: an item being edited has to be allowed to “duplicate” its own current name. If I’m just changing a photo and leave the name alone, the form is technically asking “is there an item called X?” and the answer would be “yes, this one” — but that’s me, not a conflict. So the duplicate check excludes the row being edited.

The check itself was already written ahead in Add an item, because the submit handler that lives there is what calls it. All I add here is the visible side: an alert that drops into the form when ui.itemForm.nameError is set, and the styling that makes it look like an error rather than a regular caption.

${ui.itemForm.nameError ? html`
  <p class="form-error" role="alert">An item with this name already exists.</p>
` : ''}

.form-error{margin:0;color:#e94560;font-size:.85rem}

@testcase
def test_duplicate_item_name_rejected(page):
    """Saving an item with a name that already exists shows an error
    and does not create a duplicate row."""
    clear_state(page)
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    assert page.get_by_role("alert").is_visible(), \
        "duplicate-name error not shown"
    # Form is still open (Save did not commit)
    assert page.get_by_label("Item name").is_visible()
    # Only the original Hammer chip exists
    assert page.get_by_text("Hammer", exact=True).count() == 1
    print("  PASS: duplicate name rejected")

Drop an item into a box

Now that I have boxes on one side and unboxed items on the other, I need a way to put one inside the other. The gesture I want is the one any user would guess: grab the item, drop it on the box. This is also the moment the app stops being “two lists side by side” and starts being an inventory.

The first thing the gesture has to deal with is that I want it to work on a phone. Native HTML5 drag-and-drop is a non-starter here — it doesn’t fire on touch devices, which is half my target use. So instead of native drag I use pointer events, which behave identically whether the pointer is a mouse, a stylus, or a finger. The geometry work — detecting which target the pointer is over, deciding whether the gesture counts as a drag or a tap — is identical to what Condorcet needed, so it lives in a shared engine rather than in this doc.

What I do supply is the wiring: I tell the engine which elements are sources (the item chips, marked with data-draggable) and which are targets (the box cards and the Unassigned section, both marked with data-drop-target), and I listen for the dropzone:drop event the engine fires when a drag lands. The handler reads the dropped chip’s item id off the source element, the destination box id off the target, and writes the new boxId cell. TinyBase’s listener does the rest — the chip re-renders inside its new container on the next tick.

@testcase
def test_drop_item_into_box(page):
    """Drag an unassigned item onto a box → the item leaves the Unassigned area."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    enable_drag(page)
    page.get_by_text("Hammer").drag_to(
        page.get_by_role("button", name="Box workshop"))
    assert page.get_by_text("Hammer").is_visible(), "Hammer disappeared after drop"
    assert not page.get_by_role("heading", name="Unassigned").is_visible(), \
        "Unassigned heading still visible — Hammer didn't move into the box"
    print("  PASS: drop item into box")

document.addEventListener('dropzone:drop', e => {
    const itemId = e.detail.sourceEl.dataset.itemId;
    if(!itemId) return;
    const boxId = e.target.dataset.boxId || '';
    store.setCell('items', itemId, 'boxId', boxId);
});

The data-draggable marker on the chip was added back in Add an item; the Unassigned section carries data-drop-target in its own template too. What this chapter has to add is the marker on the box card — slotted into Add a box’s attribute slot.

?data-drop-target=${ui.dragEnabled}

Now that items can live inside boxes, there’s a case for Delete a box that I deferred earlier: what happens to the items in a box I’m about to delete? I don’t want them to vanish with the box — items are the actual things I’m tracking, and losing them because their container went away would be worse than the original “can’t find my screws” problem. So when a box is deleted, its contained items go back to Unassigned. Delete a box already encodes that policy; with the drop gesture finally available I can pin it with a test.

@testcase
def test_delete_box_with_items(page):
    """Deleting a box with items inside returns the items to Unassigned."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    photoBtn = page.get_by_role("button", name="Box workshop")
    enable_drag(page)
    page.get_by_text("Hammer").drag_to(photoBtn)
    # Tap the photo button specifically — tapping the box li
    # centroid would land on the Hammer chip now nested inside,
    # routing to item-edit instead of box-edit.
    photoBtn.click()
    page.once("dialog", lambda d: d.accept())
    page.get_by_role("button", name="Delete").click()
    assert not page.get_by_role("button", name="Box workshop").is_visible(), \
        "thumbnail still listed after delete"
    assert page.get_by_text("Hammer").is_visible(), \
        "Hammer disappeared with the box"
    assert page.get_by_role("heading", name="Unassigned").is_visible(), \
        "Hammer didn't return to Unassigned"
    print("  PASS: delete box with items")

Drag mode is opt-in

With the drop gesture wired up, a phone-shaped reality kicks in. My thumb scrolling down the list to read what’s in a box is, to the pointer-event engine, indistinguishable from the start of a drag. I notice the misfire only after a chip has landed where it doesn’t belong, and now I have to find it and put it back. A smarter threshold can’t separate “I meant this” from “I didn’t” — only a mode can. So the page starts in read mode where the drag affordances aren’t even on the page; rearranging is what I tap into when I actually mean to move things around.

The flag lives in ui.dragEnabled alongside the other ephemeral bits, and starts false.

The toggle has to be one gesture away wherever the user is on the page, but I don’t want it occupying screen real estate while it isn’t being used — a permanent button (in the app-bar or as a floating action button) is visible chrome the read-mode user never needs to look at. So the toggle lives inside a contextual menu: a right-click on desktop, a long-press on touch (both fire the browser contextmenu event), and the menu pops up at the pointer position with the toggle as its single entry. Tap outside or press Escape to close it without acting.

The label names the state I’m leaving, not the one I’m in: when drag is off the entry reads Rearrange (tap it to enter rearrange mode); when on it reads Done. The verb is always what tapping it does.

ui.contextMenu carries null when closed, or ${x, y} when open, in viewport coordinates so the menu can be painted at the spot the gesture landed.

${ui.contextMenu ? html`
  <div class="context-menu" role="menu"
       style="left:${ui.contextMenu.x}px; top:${ui.contextMenu.y}px">
    <<context-menu-contextual-entries>>
    <<context-menu-global-entries>>
  </div>
` : ''}

The Rearrange/Done entry itself pours into the global slot — always offered regardless of where the gesture landed. A later chapter (Add an item directly to a box) pours into the contextual slot, which sits above the global one so the here-and-now action appears first.

<button type="button" role="menuitem"
        @click=${() => setUI({dragEnabled: !ui.dragEnabled, contextMenu: null})}>
  ${ui.dragEnabled ? 'Done' : 'Rearrange'}
</button>

Three document-level listeners wire the menu. contextmenu is where the gesture lands; it’s prevented (so the browser’s own menu doesn’t show up over ours) and opens the menu. A click outside the menu closes it. Escape closes it too — keyboard users get the same out as touch ones. Inputs and the menu itself are skipped so the user can still right-click into a text field to paste.

document.addEventListener('contextmenu', e => {
    if (e.target.closest('input, textarea, select, .context-menu')) return;
    e.preventDefault();
    // A chip carries its own identity; don't let it inherit its parent
    // box's "add item here" entry.
    const onChip = !!e.target.closest('[data-item-id]');
    const boxEl = onChip ? null : e.target.closest('[data-box-id]');
    setUI({contextMenu: {
        x: e.clientX, y: e.clientY,
        boxId: boxEl ? boxEl.dataset.boxId : null,
    }});
});
document.addEventListener('click', e => {
    if (!ui.contextMenu) return;
    if (e.target.closest('.context-menu')) return;
    setUI({contextMenu: null});
});
document.addEventListener('keydown', e => {
    if (e.key === 'Escape' && ui.contextMenu) setUI({contextMenu: null});
});

The styling pins the menu in viewport coordinates so a scroll underneath doesn’t shift it, layers it above the lists but below modal overlays, and gives the entry the dark-card look the rest of the app already uses.

.context-menu{
    position:fixed;z-index:60;
    background:var(--card);color:var(--fg);
    border:1px solid #333;border-radius:6px;
    box-shadow:0 6px 16px rgba(0,0,0,.4);
    padding:4px;min-width:140px;
}
.context-menu button{
    display:block;width:100%;text-align:left;
    background:transparent;color:inherit;
    border:0;padding:8px 12px;font:inherit;cursor:pointer;
    border-radius:4px;
}
.context-menu button:hover{background:rgba(255,255,255,.06)}

Four affordances pour into the shared drag engines: the chip’s data-draggable, the data-drop-target markers on the Unassigned section and on each box card, and the box’s reorder grip. Each of those templates (The chip, The Unassigned section, Drop an item into a box, Reorder boxes) gates its emission on ui.dragEnabled. When the flag is off, the shared engines find no source and no target, and pointer events fall through to the underlying click handlers — tap to edit, scroll to scroll.

The new contract: dragging a chip without first opening the contextual menu and tapping Rearrange cannot move it. After Rearrange, the same drag works as before; re-opening the menu shows Done, and tapping it puts the page back in read mode.

@testcase
def test_drag_off_by_default_toggle_on(page):
    """Drag is gated behind the contextual menu — off by default,
    Rearrange enables it, Done switches it back off."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    # Drag attempt while drag is off — no-op.
    page.get_by_text("Hammer").drag_to(
        page.get_by_role("button", name="Box workshop"))
    assert page.get_by_role("heading", name="Unassigned").is_visible(), \
        "Hammer moved into the box with drag disabled"
    # Enable drag mode; the same drag now lands.
    enable_drag(page)
    page.get_by_text("Hammer").drag_to(
        page.get_by_role("button", name="Box workshop"))
    assert not page.get_by_role("heading", name="Unassigned").is_visible(), \
        "Hammer didn't move after enabling drag"
    # Re-open the menu; the entry now reads Done. Selecting it returns to read mode.
    page.locator("main").click(button="right", position={"x": 5, "y": 5})
    page.get_by_role("menuitem", name="Done").click()
    # The menu closed and the next right-click again offers Rearrange.
    page.locator("main").click(button="right", position={"x": 5, "y": 5})
    assert page.get_by_role("menuitem", name="Rearrange").is_visible(), \
        "Toggle didn't return to Rearrange after Done"
    print("  PASS: drag off by default, toggles on and off")

Add an item directly to a box

Tagging every new item as Unassigned and then dragging it into a box is two gestures where one will do. When I’m standing in front of a box and want to record what just went in it, “type a name, hit Save, it’s there” is the flow. The contextual menu already knows where the gesture landed — if it was on a box, the handler captured boxId alongside the coordinates. So this chapter is just a second entry in the menu, only shown when a box is the gesture target, that opens the item form with the boxId pre-filled.

openItemForm already accepts an opts object with a boxId field; it lands in ui.itemForm.boxId and onItemFormSubmit reads it when minting a new row. Both of those joins were made in The form so the wiring is a single template entry here.

${ui.contextMenu.boxId ? html`
  <button type="button" role="menuitem"
          @click=${() => openItemForm({boxId: ui.contextMenu.boxId})}>
    Add item here
  </button>
` : ''}

The new flow: long-press (or right-click) on a box, pick Add item here, fill the name, Save. The item lands inside that box on first paint — never visits Unassigned.

@testcase
def test_add_item_directly_to_box(page):
    """Right-click on a box → 'Add item here' creates the item already inside."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Box workshop").click(button="right")
    page.get_by_role("menuitem", name="Add item here").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_role("button", name="Save").click()
    chips_in_box = page.locator(".boxes-list .items-in-box .item-name").all_inner_texts()
    assert "Hammer" in chips_in_box, \
        f"Hammer didn't land inside the box: {chips_in_box}"
    assert not page.get_by_role("heading", name="Unassigned").is_visible(), \
        "Hammer went through Unassigned despite direct add"
    print("  PASS: add item directly to box")

Find a box by searching for an item

This is the question the whole app exists to answer: “where did I put the 3mm screws?” Everything else — the boxes, the items, the drag-and-drop — is in service of being able to type a name and immediately see which box holds it.

I want the search to be live. Typing each character should immediately narrow what’s on screen; no separate Search button to press. And I want both the item and its box to be visible in the answer — knowing that “Hammer” exists is useless if it doesn’t also tell me which box it’s in. The way I make that happen is that the search filters two things at once: items whose name doesn’t match disappear, and boxes that have no surviving items inside them disappear with them. What’s left is exactly the shortest route from search query to “the box you should open.”

An empty query shows everything — the same view as before anyone typed.

The search input lives in the app-bar slot I reserved for it (app-bar wrapper). Two-way binding to ui.searchQuery: each keystroke calls setUI, which triggers a re-render, and the box list and Unassigned section both read ui.searchQuery when they decide what to keep — which is filter machinery I already wired into those templates ahead of time.

<input type="search" placeholder="Search…" aria-label="Search"
       .value=${ui.searchQuery}
       @input=${e => setUI({searchQuery: e.target.value})}>

The input fills the bar’s remaining width on its own line — at phone widths the bar wraps and the search ends up below the buttons, which leaves room to type a real query without squeezing it.

.app-bar input[type=search]{flex:1 1 100%;background:var(--card);color:var(--fg);border:1px solid #333;border-radius:6px;padding:8px 10px;font:inherit}

@testcase
def test_search_finds_item_and_box(page):
    """Searching for an item filters out non-matching items and box thumbnails."""
    clear_state(page)
    for box_name in ["workshop", "kitchen"]:
        page.get_by_role("button", name="Add a box").click()
        page.get_by_label("Photo").set_input_files(files=[{
            "name": f"{box_name}.png",
            "mimeType": "image/png",
            "buffer": MINIMAL_PNG,
        }])
        page.get_by_role("img", name="Photo preview").wait_for(state="visible")
        page.get_by_role("button", name="Save").click()
    enable_drag(page)
    for item_name, target_box in [("Hammer", "workshop"), ("Saw", "kitchen")]:
        page.get_by_role("button", name="Add an item").click()
        page.get_by_label("Item name").fill(item_name)
        page.get_by_role("button", name="Save").click()
        page.get_by_text(item_name).drag_to(
            page.get_by_role("button", name=f"Box {target_box}"))
    page.get_by_placeholder("Search").fill("Hammer")
    assert page.get_by_text("Hammer").is_visible(), "Hammer hidden after search"
    assert not page.get_by_text("Saw").is_visible(), "Saw still visible after filter"
    assert page.get_by_role("button", name="Box workshop").is_visible(), \
        "workshop thumbnail hidden though it owns Hammer"
    assert not page.get_by_role("button", name="Box kitchen").is_visible(), \
        "kitchen thumbnail visible though Saw was filtered"
    print("  PASS: search finds item and box")

The “empty query shows everything” branch is its own contract — clearing the input has to bring back every item and box I’d filtered out, otherwise a typo with no backspace leaves me staring at half my inventory. The test types a query that filters everything out, then clears it, then checks that the hidden things are back.

@testcase
def test_search_empty_query_shows_all(page):
    """Clearing the search restores every item and box."""
    clear_state(page)
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    page.get_by_role("button", name="Add an item").click()
    page.get_by_label("Item name").fill("Saw")
    page.get_by_role("button", name="Save").click()
    page.get_by_placeholder("Search").fill("nonexistent")
    assert not page.get_by_text("Saw").is_visible()
    assert not page.get_by_role("button", name="Box workshop").is_visible()
    page.get_by_placeholder("Search").fill("")
    assert page.get_by_text("Saw").is_visible(), \
        "Saw didn't come back when search was cleared"
    assert page.get_by_role("button", name="Box workshop").is_visible(), \
        "Box workshop didn't come back when search was cleared"
    print("  PASS: empty query shows all")

Browse all items at a glance

Search works when I already know the name. The other way I reach for my inventory is the opposite: I’m not sure what I’m looking for, I just want to see what I own and pick the thing I need by eye. A flat alphabetical catalog of every item, with each chip wearing the same background colour as it does inside its box (cf. Items with background colour), is exactly the right surface for that.

But the catalog is the occasional lookup, not the day-to-day view — keeping it permanently on screen would steal a stripe of vertical space from the box list every visit, for a feature I only reach for now and then. So the catalog is toggled: a Catalog button in the app-bar opens it as an overlay above the page; tapping a chip closes the overlay and jumps the page to the chip’s box; tapping the close button or outside dismisses without acting. Search filters the catalog contents at the same time as the box list, so a query typed before opening the catalog already narrows what shows up.

ui.catalogOpen is the flag, false by default.

<button type="button" @click=${() => setUI({catalogOpen: true})}>Catalog</button>

jumpToBox is the navigation primitive — it closes the catalog as a side-effect, finds the right element, and asks the browser to scroll it into the middle of the viewport. Assigned items scroll to their box card; unassigned items scroll to the Unassigned section so the user lands on something meaningful either way.

A scroll alone, though, drops the user in a list of similar cards without flagging which one was the target. So jumpToBox also paints a brief pulse on the destination — a glowing halo that fades over about a second — so the eye catches the right card before settling. .spotlight is the marker class; an animationend listener removes it so the next jump can re-trigger the animation cleanly.

function jumpToBox(boxId){
    setUI({catalogOpen: false});
    const sel = boxId
        ? `li[data-box-id="${boxId}"]`
        : '.unassigned';
    const el = document.querySelector(sel);
    if(!el) return;
    el.scrollIntoView({behavior: 'smooth', block: 'center'});
    el.classList.remove('spotlight');
    // Force reflow so re-adding the class restarts the animation.
    void el.offsetWidth;
    el.classList.add('spotlight');
    el.addEventListener('animationend',
        () => el.classList.remove('spotlight'),
        {once: true});
}

The chip list itself is one function: it pulls every item out of the store, filters by the current search query (the same one the box list reads), sorts alphabetically, and renders one button per surviving id. If nothing survives — empty inventory or a query that matches nothing — the catalog opens to an empty card rather than refusing to open.

function allItemsTemplate(){
    const itemsTable = store.getTable('items');
    const queryAI = ui.searchQuery.trim().toLowerCase();
    const ids = Object.keys(itemsTable)
        .filter(id => !queryAI ||
                      itemsTable[id].name.toLowerCase().includes(queryAI))
        .sort((a, b) =>
            itemsTable[a].name.localeCompare(itemsTable[b].name));
    return html`
      <<all-items-markup>>
    `;
}

Each chip paints its name on a CSS pseudo-element rather than in a text node, so the in-box and Unassigned chips — which look almost identical from a text-only match — stay the unambiguous targets of locators that drag, tap or read them. The catalog chip is reachable through its aria-label as a button-roled surface: “Hammer button” is distinct from “the Hammer chip in workshop”.

<section class="all-items" aria-label="All items">
  ${ids.map(id => {
      const item = itemsTable[id];
      const style = item.bgColor
          ? `background:${item.bgColor};color:${readableForeground(item.bgColor)}`
          : '';
      return html`
        <button type="button" class="all-items-chip"
                data-name=${item.name}
                aria-label=${item.name}
                style=${style}
                @click=${() => jumpToBox(item.boxId)}>
        </button>
      `;
  })}
</section>

The overlay is a backdrop + a centred sheet, exactly the same modal pattern the box and item forms use, with a header carrying the title and a close affordance. The backdrop’s click closes the catalog too — same out as the close button, same out the user has come to expect from the form.

The shell reuses the shared modal-shell CSS via the .modal-shell class. The .catalog-shell modifier just widens the card to 480px (via the shared block’s --modal-max-width variable) and swaps overflow-y:auto on the shell for overflow:hidden + a flex column, so the sticky header stays put and only .catalog-body scrolls. The chip itself is visually quiet — a thin border, modest padding — to keep a long list scannable. Its visible label is painted from data-name via a ::before pseudo-element so the in-box and Unassigned <li class“item”>= chips remain the unambiguous targets of locators that drag, tap or read them.

.catalog-shell{
    --modal-max-width:480px;
    display:flex;flex-direction:column;overflow:hidden;
}
.catalog-header{
    display:flex;align-items:center;justify-content:space-between;
    padding:10px 14px;border-bottom:1px solid #2a2d44;
}
.catalog-header h2{margin:0;font-size:1rem}
.catalog-close{
    background:transparent;color:var(--fg);border:0;
    font-size:1.4rem;cursor:pointer;padding:0 6px;line-height:1;
}
.catalog-body{padding:8px 12px;overflow-y:auto;flex:1}
.all-items{display:flex;flex-wrap:wrap;gap:6px;margin:0}
.all-items-chip{
    appearance:none;border:1px solid #333;border-radius:14px;
    padding:3px 10px;background:var(--card);color:var(--fg);
    font:inherit;font-size:.85rem;cursor:pointer;
}
.all-items-chip::before{content:attr(data-name)}

@testcase
def test_catalog_overlay_navigates_to_box(page):
    """Catalog opens via toggle, shows every item with its colour, jumps to box."""
    clear_state(page)
    box_names = [chr(ord('a') + i) * 3 for i in range(20)]
    for name in box_names:
        page.get_by_role("button", name="Add a box").click()
        page.get_by_label("Photo").set_input_files(files=[{
            "name": f"{name}.png",
            "mimeType": "image/png",
            "buffer": MINIMAL_PNG,
        }])
        page.get_by_role("img", name="Photo preview").wait_for(state="visible")
        page.get_by_role("button", name="Save").click()
    target_box = f"Box {box_names[-1]}"
    page.get_by_role("button", name=target_box).click(button="right")
    page.get_by_role("menuitem", name="Add item here").click()
    page.get_by_label("Item name").fill("Hammer")
    page.get_by_label("Background colour").fill("#ff0000")
    page.get_by_role("button", name="Save").click()
    # The catalog is closed by default.
    assert page.get_by_role("dialog", name="Catalog").count() == 0, \
        "catalog visible before toggle"
    # Open the catalog from the app-bar.
    page.get_by_role("button", name="Catalog").click()
    chip = page.get_by_role("button", name="Hammer")
    chip.wait_for(state="visible")
    bg = chip.evaluate("el => getComputedStyle(el).backgroundColor")
    assert "255, 0, 0" in bg, f"catalog chip colour mismatch: {bg}"
    # Scroll the page to top so the last box is off-screen.
    page.evaluate("window.scrollTo(0, 0)")
    chip.click()
    # The catalog closed and the target box is now in view.
    assert page.get_by_role("dialog", name="Catalog").count() == 0, \
        "catalog still open after jumping"
    page.wait_for_function(
        f"""() => {{
          const btn = document.querySelector('[aria-label="{target_box}"]');
          if (!btn) return false;
          const r = btn.getBoundingClientRect();
          return r.top >= 0 && r.bottom <= window.innerHeight;
        }}""",
        timeout=2000,
    )
    # The destination is briefly highlighted so the user spots it.
    page.wait_for_function(
        f"""() => {{
          const btn = document.querySelector('[aria-label="{target_box}"]');
          return btn && btn.closest('li.box').classList.contains('spotlight');
        }}""",
        timeout=2000,
    )
    print("  PASS: catalog overlay navigates to box")

Persistence across reloads

Everything I’ve built so far lives in memory. Close the tab and the inventory is gone — which would make the whole app a cosmetic exercise. So I want the data to outlive the tab.

This is also the chapter where the choice of TinyBase has to pay rent: I picked it partly because its persisters are built in, and now I get to use one. The IndexedDB persister, wired back in Initial load, mirrors every store mutation to a browser-local database called organiser. On the next boot startAutoLoad reads it back before the first render, so the user never sees an empty page after a reload.

So there’s no new code earned by this chapter, only a test — the regression guard that ensures a box added in one session is still there in the next.

The race between Save and reload is what the data-persist-seq signal from Initial load exists to settle: the boot wires a manual save-on-change listener that bumps the attribute once the IndexedDB write returns. The test reads the current value, clicks Save, waits for the attribute to change, then reloads. No sleep, no guess at the debounce window.

@testcase
def test_persistence(page):
    """A box added in one session is still there after a reload."""
    clear_state(page)
    seq_before = page.evaluate(
        "() => parseInt(document.body.getAttribute('data-persist-seq') || '0')")
    page.get_by_role("button", name="Add a box").click()
    page.get_by_label("Photo").set_input_files(files=[{
        "name": "workshop.png",
        "mimeType": "image/png",
        "buffer": MINIMAL_PNG,
    }])
    page.get_by_role("img", name="Photo preview").wait_for(state="visible")
    page.get_by_role("button", name="Save").click()
    assert page.get_by_role("button", name="Box workshop").is_visible()
    page.wait_for_function(
        f"() => parseInt(document.body.getAttribute('data-persist-seq') || '0') > {seq_before}"
    )
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    assert page.get_by_role("button", name="Box workshop").is_visible(), \
        "box gone after reload — persistence not working"
    print("  PASS: persistence")

Sync across devices

Persistence buys me “same device, across reloads.” What I actually want is “phone and laptop agree on the same inventory, and a backup of both exists somewhere I don’t have to think about.” I add a box on my phone in the workshop; I open the laptop later in the kitchen; the box is there. And if the small server I run dies and restarts, nothing is lost.

TinyBase ships a WebSocket synchronizer that does most of this for me — paired with a small server-side counterpart, it reconciles writes between clients and the server using HLC timestamps. (This is what I picked MergeableStore for back in Initial load; without HLCs the merge would lose writes when two devices edit concurrently.) The server-side counterpart is small enough that I write it myself — see Sync server, Node side for the source — and the file persister it runs against is what makes a restart safe.

A few choices I want to make explicit before the code lands.

Opt-in by design. I don’t want a fresh open of the app to hit the network just because the URL says so. Sync activates only when the user has visited the app at least once with a ?sync_url=ws://… in the query string. The URL gets stashed in localStorage and the parameter stripped from the address bar on the spot, so the URL the user later shares or bookmarks doesn’t leak the sync endpoint. Without that one-time parameter the app stays purely local — and quiet.

Sync after the UI is ready. I set data-app-ready before I open the WebSocket, on purpose. The user can already interact while the connection negotiates, and if the server is unreachable the app doesn’t sit on a spinner waiting.

Surface the connection state. When my writes might or might not be travelling to a second device, I want to see at a glance which it is. A small label in the app-bar’s status slot — Sync off, connecting, connected, disconnected — tells me. It also gives the sync test a deterministic signal to wait on, which makes the test something other than a timing exercise.

<span class="sync-status" data-status=${ui.syncStatus} role="status">Sync ${ui.syncStatus}</span>

The colour shift tracks the state — muted when off, orange while negotiating, green when connected, red when the connection drops. At a glance, in peripheral vision, the colour is enough.

.sync-status{font-size:.8rem;padding:4px 8px;border-radius:6px;color:var(--muted)}
.sync-status[data-status=connecting]{color:#f9a826}
.sync-status[data-status=connected]{color:#06d6a0}
.sync-status[data-status=disconnected]{color:#e63946}

The cross-device test does what a user would do: open the app on two browser contexts (each one acts as a separate device), add a box on one, see it appear on the other. Two things make this test deterministic rather than a race.

First, each test run reserves a fresh path on the sync server — the path scopes a TinyBase “room”, so concurrent or replayed tests can’t leak state into each other.

Second, the order of who subscribes when. If B opens after A has already pushed, B is a late joiner and has to pull initial state, which races whatever assertion the test made. I avoid the race by having both contexts wait for Sync connected before A writes anything — at that point both are live subscribers, and A’s push reaches B through the server’s broadcast path rather than through the pull-on-join path. The test’s wait then becomes a wait on a real signal, not a sleep behind a guessed delay.

@testcase
def test_sync_propagation(page):
    """A box added on device A appears on device B via the sync server."""
    sync_url = require_sync_server() + "/" + uuid.uuid4().hex
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        pageB = open_app(ctxB, f"{BASE_URL}?sync_url={sync_url}")
        pageB.get_by_text("Sync connected").wait_for(state="visible")
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        pageA.get_by_text("Sync connected").wait_for(state="visible")
        pageA.get_by_role("button", name="Add a box").click()
        pageA.get_by_label("Photo").set_input_files(files=[{
            "name": "workshop.png",
            "mimeType": "image/png",
            "buffer": MINIMAL_PNG,
        }])
        pageA.get_by_role("img", name="Photo preview").wait_for(state="visible")
        pageA.get_by_role("button", name="Save").click()
        pageA.get_by_role("button", name="Box workshop").wait_for(state="visible")
        pageB.get_by_role("button", name="Box workshop").wait_for(
            state="visible", timeout=10000)
    print("  PASS: sync propagation")

The startup code does the dance: consume the one-shot URL from the query and persist it to localStorage, then loop — forever, in the background — trying to keep a live WebSocket open. When the connection holds, the synchronizer runs over it and the indicator reads connected. When it drops (idle timeout from a reverse-proxy, the phone going to sleep, the laptop closing its lid), the indicator flips to disconnected, the loop waits a beat with exponential backoff (1s, 2s, 4s, … capped at 30s), and tries again. The TinyBase synchronizer itself doesn’t reconnect on its own — every reconnect is a fresh createWsSynchronizer over a fresh WebSocket. If no URL was ever set, the loop never starts — the app stays purely local.

function setSyncStatus(s){
    setUI({syncStatus: s});
}

function openSocket(url){
    return new Promise((resolve, reject) => {
        const ws = new WebSocket(url);
        ws.addEventListener('open', () => resolve(ws), {once: true});
        ws.addEventListener('error', reject, {once: true});
    });
}

async function startSync(){
    const params = new URLSearchParams(location.search);
    const fromParam = params.get('sync_url');
    if(fromParam){
        localStorage.setItem('organiser.sync_url', fromParam);
        const clean = new URL(location);
        clean.searchParams.delete('sync_url');
        history.replaceState(null, '', clean);
    }
    const syncUrl = localStorage.getItem('organiser.sync_url');
    if(!syncUrl){ setSyncStatus('off'); return; }
    let backoff = 0;
    while(true){
        if(backoff) await new Promise(r => setTimeout(r, backoff));
        setSyncStatus('connecting');
        try {
            const ws = await openSocket(syncUrl);
            setSyncStatus('connected');
            backoff = 0;
            const sync = await createWsSynchronizer(store, ws);
            await sync.startSync();
            await new Promise(resolve =>
                ws.addEventListener('close', resolve, {once: true}));
        } catch(_){}
        setSyncStatus('disconnected');
        backoff = Math.min(30000, backoff ? backoff * 2 : 1000);
    }
}

Sync server, Node side

The server is small enough that I’d rather understand every line than depend on someone else’s hosted sync. TinyBase already gives me the protocol; what I write is the relay around it.

Why Bun. I’d reach for Node out of habit, but TinyBase’s peerOptional fanout (React, React Native, electric-sql, and so on) trips npm’s strict resolver in a way that I’d spend more time apologising to than running. Bun resolves peer optionals lazily and runs .mjs directly, which lets me hand the server over to it and stop thinking about it.

Why a file persister on the server too. The whole point of adding a server is that the inventory survives one device dying. If the server is a pure relay, a pod restart loses every client that wasn’t connected at the moment. So each room (TinyBase’s word for a per-path namespace) is mirrored to a JSON file on disk — loaded on first connection, saved on every change. A restart picks the file back up and clients reconcile against it. The server is a real backup, not just a message bus.

Where the source lives. Packaged as konubinix/tinybasesync:0.1.1 via the nomad/docker/Earthfile — same shape as the equivalent sync server in Condorcet. The server.mjs and package.json sit in nomad/docker/tinybasesync/, and clk nd build tinybasesync builds and pushes the image. The Playwright test fixture and the nomad job both pull from the registry.

Provisioning a device

Once the server is up, joining a new device to it has to be easy enough that I’ll actually do it when I sit down at the laptop, but not so open that anyone with the URL can sync into my inventory. The compromise is a one-shot magic-link handshake: the WebSocket endpoint sits behind a traefik middleware (nomad job for the configuration) that requires a token on first contact. clk authorizationserver generate mints a URL with the token already baked in and the ?sync_url parameter pre-populated, so the first time I open it on the new device, startSync writes the URL into localStorage and the parameter strips itself from the address bar.

The path tinybasesync/organiser is what scopes the room (see Sync across devices for room semantics) — I host other sync-using apps under the same server, and the per-path isolation is what keeps their writes from crossing each other.

So when I provision a phone or a laptop I run one of these:

clk authorizationserver generate --redirect 'https://sam.konubinix.eu/organiser?sync_url=wss://konubinix.eu/tinybasesync/organiser' tinybasesync/organiser sam-phone
clk authorizationserver generate --redirect 'http://localhost:9682/debug/organiser?sync_url=ws://localhost:9682/tinybasesync/organiser' tinybasesync/organiser sam-phone-debug

A trap I keep tripping over when I add a new sync path. The request to a new path is also gated by two separate lighttpd allow-list regexes — one on the host’s localhost:9682 stack (~/.config/clk/bin/podcast.serve), one on the phone’s (~/.config/clk/bin/lib/androidbootstrap, the lighttpd step). They are independent processes, and a path missing from either one 404s the request before it ever reaches traefik. Fixing one without fixing the other looks identical to “the new path doesn’t work” until I notice. So: when I add a path, edit both regexes.

PWA shell

At this point I have a functioning web app, but I want more than that. I want to add it to my phone’s home screen and open it like a native app — no browser chrome, no URL bar, just the inventory. I want it to launch instantly even when the workshop has no signal, and I want the photos I took before I went down to the basement to be there when I look for them.

Three small things turn the web app into that PWA.

The first is a manifest: the metadata the OS reads when I add the app to my home screen. The name it shows under the icon, the theme colour the system uses for the status bar around the fullscreen view, and the icon itself. I draw the icon inline as an SVG data-URL so I don’t have to maintain a separate file — one document, no out-of-band assets.

{
    "name": "Organiser",
    "short_name": "Organiser",
    "description": "Local-first inventory of physical boxes and their contents.",
    "start_url": ".",
    "display": "standalone",
    "background_color": "#1b1d2e",
    "theme_color": "#1b1d2e",
    "icons": [
        {
            "src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='96' fill='%231b1d2e'/><text x='256' y='350' font-size='280' text-anchor='middle' fill='%23f9a826'>O</text></svg>",
            "sizes": "512x512",
            "type": "image/svg+xml",
            "purpose": "any maskable"
        }
    ]
}

The second is a service worker. The pattern I want is cache-first with write-through: the first visit fetches all the app’s assets from the network and stuffs them in a cache; every later visit opens straight from the cache and survives offline. The catch is that “cache forever” turns into “I deployed but my phone still runs the old app” within about ten minutes. I solve that by naming the cache after the build’s hash — change any build artefact, the hash changes, the old cache is dropped on the next activation. The cache-handling code itself is the same as in every other PWA I’ve written, so it lives in a shared block; what’s app-specific is which files to cache and which cache name to give them. The shared register block also skips localhost and 127.0.0.1, so the dev tangle and the Playwright suite never get a stale cache served to them in the middle of debugging.

const CACHES = [
    { name: 'organiser-<<build-hash()>>' },
];
const ASSETS = ['./', './index.html', './app.js', './manifest.json'];

<<shared_blocks.org:sw-handlers-ex()>>

The third piece is a loading ring. With the rest in place, there’s still the moment between “tap the icon” and “the JS has parsed and the persister has finished loading” — easily a beat or two on a slow phone. An empty page during that beat looks broken; a small spinner says “I’m here, I’m starting.” It sits behind every other UI element and disappears the moment data-app-ready flips on the body — the same signal the tests wait on.

<div id="loading"><div class="loading-ring"></div></div>

#loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:9999}
body[data-app-ready] #loading{display:none}
.loading-ring{width:44px;height:44px;border:3px solid #333;border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}

One small operational thing. PWAs cache aggressively enough that the question “which build did this device actually pick up?” comes up regularly after a deploy. So I show the build hash in the corner of the screen — a tiny monospaced tag I can read at a glance to confirm.

<span class="build-tag" title="Build"><<build-hash()>></span>

.build-tag{position:fixed;top:calc(env(safe-area-inset-top) + 8px);left:8px;font-size:.65rem;color:var(--muted);font-family:monospace;pointer-events:none;z-index:50}

Playwright tests

Every feature chapter so far has a Playwright test sitting next to its prose, and the runner I’m writing here is what ties them together. Two principles shape it.

The first is that tests only touch what I as a user can see and do. Visible text, accessible roles, labelled inputs, placeholders — that’s it. No reaching into the store, no test-only hooks, no implementation-detail selectors. The moment a test knows about a class name or a private state shape, it breaks on every refactor and stops being a contract about what the user experiences.

The second is that I don’t want to maintain a central list of tests. Every time I forget to register a new test, the suite silently shrinks and I find out at the next regression. Instead, each test registers itself with a decorator at definition time — define it next to its feature, it lands in TESTS, the runner picks it up in source order.

TESTS = []

def testcase(fn):
    """Registration decorator: each =@testcase= pushes the function
    into =TESTS= in definition order."""
    TESTS.append(fn)
    return fn

Each test runs against a clean slate. If I don’t wipe the IndexedDB and localStorage between tests, the previous test’s rows leak into the next one and the assertions start passing or failing for reasons that have nothing to do with what they’re checking. The wipe has to happen after a navigation (so the databases exist to be deleted) and the page has to be reloaded after the wipe (so the persister boots against the empty database). Then it waits on the same data-app-ready signal the rest of the helpers use.

def clear_state(page):
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    page.evaluate("""async () => {
      const dbs = await indexedDB.databases();
      await Promise.all(dbs.map(d => new Promise(res => {
        const r = indexedDB.deleteDatabase(d.name);
        r.onsuccess = r.onerror = r.onblocked = () => res();
      })));
      localStorage.clear();
    }""")
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")

def enable_drag(page):
    """Open the contextual menu and pick Rearrange."""
    page.locator("main").click(button="right", position={"x": 5, "y": 5})
    page.get_by_role("menuitem", name="Rearrange").click()

The sync test from Sync across devices needs to simulate two separate devices, which means two browser contexts — each one with its own IndexedDB and localStorage, otherwise they share state and “two devices” is a fiction. I bundle the two-context setup with a small open_app helper that does the navigation + app-ready wait that clear_state also does, so each context reaches the same starting line.

from contextlib import contextmanager

def open_app(ctx, url):
    page = ctx.new_page()
    page.set_default_timeout(5000)
    page.goto(url, timeout=15000)
    page.wait_for_selector("[data-app-ready]", timeout=15000)
    return page

@contextmanager
def two_contexts(browser):
    ctxA = browser.new_context(viewport=PHONE_VIEWPORT)
    ctxB = browser.new_context(viewport=PHONE_VIEWPORT)
    try:
        yield ctxA, ctxB
    finally:
        ctxA.close()
        ctxB.close()

The sync test also needs the actual sync server running, but I don’t want every test invocation to spin one up — the docker start takes seconds, and most tests don’t care. So require_sync_server is lazy and cached: the first sync test that asks for it gets a container started, and every later sync test in the same run reuses it. atexit stops the container once at the very end. Same shape as the equivalent fixture in Condorcet.

The imports and a module-level state cell that holds the lazy-started container handle:

import atexit
import base64
import shutil
import socket
import subprocess
import time

SYNC_IMAGE = "konubinix/tinybasesync:0.1.1"
_SYNC_STATE = {"container": None, "url": None}

Tests may run in parallel later, so a hard-coded port would collide with itself eventually. Asking the kernel for port 0 and reading back what it gave me is the cheapest way to get one I know is free.

def _free_port():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]

Now the readiness probe — where I burned an hour. Bun binds the WebSocket listener a beat before it wires up the upgrade-handler chain, so “the TCP port is open” doesn’t mean “the server is speaking the protocol.” The first test that fires a new WebSocket(...) would hit the open port but hang on the upgrade. The fix is to actually drive a WebSocket upgrade from Python — open a socket, send the upgrade headers, only declare ready when an HTTP 101 comes back. At that point the handler chain is wired and the test won’t race.

def _wait_ws_handshake(port, timeout=30.0):
    deadline = time.monotonic() + timeout
    probe_path = f"/__probe_{uuid.uuid4().hex[:8]}"
    last_status = b""
    while time.monotonic() < deadline:
        try:
            sock = socket.create_connection(("localhost", port), timeout=1)
            sock.settimeout(2)
            key = base64.b64encode(os.urandom(16)).decode()
            sock.sendall((
                f"GET {probe_path} HTTP/1.1\r\n"
                f"Host: localhost:{port}\r\n"
                "Upgrade: websocket\r\n"
                "Connection: Upgrade\r\n"
                f"Sec-WebSocket-Key: {key}\r\n"
                "Sec-WebSocket-Version: 13\r\n"
                "\r\n"
            ).encode())
            buf = b""
            while b"\r\n" not in buf and len(buf) < 256:
                chunk = sock.recv(64)
                if not chunk:
                    break
                buf += chunk
            sock.close()
            if buf.startswith(b"HTTP/1.1 101"):
                return True
            last_status = buf.split(b"\r\n", 1)[0]
        except OSError:
            pass
        time.sleep(0.2)
    return last_status

Putting the pieces together: reuse the cached URL if there is one, otherwise start the container, wait for the real handshake, stash the URL for later sync tests. If docker isn’t on PATH I fail loudly rather than burn the test-timeout budget waiting for a container that will never come up.

def require_sync_server():
    if _SYNC_STATE["url"]:
        return _SYNC_STATE["url"]
    if not shutil.which("docker"):
        raise RuntimeError("docker not found — required for sync tests")
    port = _free_port()
    container = f"organiser-sync-{uuid.uuid4().hex[:8]}"
    try:
        subprocess.check_call(
            ["docker", "run", "-d", "--rm", "--name", container,
             "-p", f"127.0.0.1:{port}:8044",
             SYNC_IMAGE],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    except subprocess.CalledProcessError as e:
        raise RuntimeError(
            f"docker run {SYNC_IMAGE} failed — has the image been built "
            f"and published? See the Earthfile target in [Sync server, "
            f"Node side]."
        ) from e
    ready = _wait_ws_handshake(port)
    if ready is not True:
        subprocess.run(["docker", "stop", container],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        raise RuntimeError(
            f"sync server didn't complete WS handshake on :{port} "
            f"(last status line: {ready!r})"
        )
    _SYNC_STATE["container"] = container
    _SYNC_STATE["url"] = f"ws://localhost:{port}"
    return _SYNC_STATE["url"]

And the teardown — registered with atexit so the container dies with the test process even if the runner crashes mid-suite.

@atexit.register
def _cleanup_sync():
    c = _SYNC_STATE.get("container")
    if c:
        subprocess.run(["docker", "stop", c],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

The last piece of setup is the imports block, which is doing more than just import. There are three things I have to take care of before any test runs.

Find the browsers. The Nix shell I run this from doesn’t put the Playwright browsers on a path Playwright’s loader knows about. So I look for the playwright-browsers entry in buildInputs and point PLAYWRIGHT_BROWSERS_PATH at it. If the user already set the variable, I leave it alone.

Provide a fixture file. Several tests need to feed a file into the photo input, which means I need a real image. Rather than ship a binary fixture alongside the test runner, I encode a 1×1 PNG inline as hex — it’s the smallest possible valid PNG, small enough to read at a glance, valid enough to ride the full resize-encode-store-render pipeline.

Pin the viewport. The app is phone-first, so I want the tests to run at a portrait phone resolution. Otherwise a desktop-width context might pass tests that would fail on the shape the user actually holds.

import os, re, sys, uuid
if "PLAYWRIGHT_BROWSERS_PATH" not in os.environ:
    for p in os.environ.get("buildInputs", "").split():
        if "playwright-browsers" in p:
            os.environ["PLAYWRIGHT_BROWSERS_PATH"] = p
            break
from playwright.sync_api import sync_playwright
BASE_URL = os.environ.get("ORGANISER_URL", "http://localhost:9682/debug/organiser/")
PHONE_VIEWPORT = {"width": 400, "height": 800}

MINIMAL_PNG = bytes.fromhex(
    "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
    "0000000d49444154789c63fcffff3f0300050001a5f645960000000049454e44ae426082"
)

And finally the runner itself. I want three things from it: run the whole suite by default; let me run a single test by name when I’m iterating; and let me stop on the first failure so I don’t have to scroll past a wall of cascading reds. Positional arguments filter tests by name substring (OR across multiple), -x stops on the first failure, --headed shows the browser. The shared page-and-context is fine — every test resets through clear_state anyway.

def main():
    argv = sys.argv[1:]
    headed = "--headed" in argv
    stop_on_fail = "-x" in argv or "--exitfirst" in argv
    patterns = [a for a in argv
                if a not in ("--headed", "-x", "--exitfirst")]
    selected = [t for t in TESTS
                if not patterns or any(p in t.__name__ for p in patterns)]
    if not selected:
        print(f"no test matches {patterns}")
        sys.exit(2)
    with sync_playwright() as pw:
        browser = pw.chromium.launch(headless=not headed)
        ctx = browser.new_context(viewport=PHONE_VIEWPORT)
        page = ctx.new_page()
        page.set_default_timeout(5000)
        passed = failed = 0
        for t in selected:
            try: t(page); passed += 1
            except Exception as e:
                print(f"  FAIL: {t.__name__}: {e}"); failed += 1
                if stop_on_fail: break
        browser.close()
        print(f"\n{passed} passed, {failed} failed out of {len(selected)}")
        sys.exit(1 if failed else 0)

if __name__ == "__main__":
    main()