Simple Pwa Organiser
FleetingI 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">📦</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()