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, tend to unstick and curl, and are 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, sync optional.
Live: https://sam.konubinix.eu/organiser/.
choice of technology
This is my fourth try at local-first programming. Let’s recall what I want:
- Locality of Behaviour;
- fast iterations in literate programming;
- a stack that’s fast to start and iterate — no big boilerplate or build-config tax before I write anything useful.
I already tried:
- alpine + yjs on the podcast player and on score counter tally;
- petite-vue + automerge on scrutin de Condorcet randomisé entre amis.
I’m clearer about what gets in the way. Time for another stack.
The pattern is the same on all three: two reactivity engines competing for the same state.
Counted in lines of bridge code — the glue that keeps the renderer’s reactive state and the CRDT (a data structure that lets multiple devices edit concurrently and merge without losing writes) in step:
| attempt | bridge LoC |
|---|---|
| podcast player (alpine + yjs) | ~225 |
| score counter tally (alpine + yjs) | ~45 |
| Condorcet (petite-vue + automerge) | ~170 |
The bridges to sync the rendering engine and the CRDT one were harder to maintain than expected, and traps like cycle or desync are frequent.
So this time, let’s try one source of truth: a single TinyBase store, with store and sync in one package and no rendering-side datastore at all. Lit carries the render layer — small custom-element classes that read from properties the root passes down and dispatch events back up when the user interacts. The store drives; the components respond.
The page is a single <app-root> element. When the store
changes, <app-root> re-renders top to bottom; its children
read state from their properties and dispatch events back up.
<app-root>’s handlers mutate the store, which triggers the
next render. One loop; no component drives itself. Re-rendering
the whole tree on every change won’t scale to a large component
tree, but for an app this small it’s plenty.
Writes bubble up as events, reads come down as props; the two
meet only at <app-root> and the store.
The widgets come from Shoelace — dialogs, buttons, inputs,
menus — built on Lit, so they slot into the templates as
naturally as any other custom element, themed through CSS custom
properties so the dark palette is one <link> tag away.
Tailwind is loaded too, handy for body-level resets and utility
classes when a feature wants them; the bespoke bits (boxes grid,
chip pills, empty-state hint) stay on plain CSS. Both come from
the Play CDN — no build step, just an <script> tag.
The price of the TinyBase pick lands on the other side: it’s JS-only. A future Python importer or a backend reading the inventory will parse the JSON the sync server writes, or drive a JS runtime as a subprocess. Yjs and Automerge would have given me a real cross-language peer — at the cost of bringing the reactivity duality back.
Setting up the store
External libraries
Let’s import lit and tinybase.
<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": "https://esm.sh/lit@3"
}
}
</script>
Store schema
What I want to remember is small: a box I can recognise on sight, 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:
photo— the thumbnail.order— an integer 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:
name— the thing I was looking for.boxId— its current box (empty when unassigned, see Drop an item into a box).image— optionally, its own thumbnail for the chip (see Items with photos).bgColor— optionally, a background colour to group it visually (see Items with background colour).
Initial load
Let’s keep the navigation state in a ui object. The fields
will be introduced later in the doc; this skeleton just collects
the pieces. Each <<…>> below refers to one of them.
const ui = {
<<ui-box-form>>
<<ui-item-form>>
<<ui-search>>
<<ui-sync-status>>
<<ui-drag-enabled>>
<<ui-context-menu>>
<<ui-catalog-open>>
};
function setUI(updates){
Object.assign(ui, updates);
renderApp();
}
The renderer is a single <app-root> sitting in the body’s
<main>. The class delegates render to appTemplate, and
connectedCallback wires the event routes.
<main><app-root></app-root></main>
class AppRoot extends LitElement {
createRenderRoot(){ return this; }
connectedCallback(){
super.connectedCallback();
installAppRootHandlers(this);
}
render(){ return appTemplate(this); }
}
customElements.define('app-root', AppRoot);
Each child-dispatched event routes to a module-scope function
that touches the store or ui. Like the ui fields above, the
routes are disseminated: every feature chapter registers its own
next to the function it calls, and this skeleton just collects
them.
function installAppRootHandlers(root){
const on = (type, fn) => root.addEventListener(type, fn);
<<handlers-box-form>>
<<handlers-edit-box>>
<<handlers-delete-box>>
<<handlers-item-form>>
<<handlers-edit-item>>
<<handlers-delete-item>>
<<handlers-item-colour>>
<<handlers-drag>>
<<handlers-add-item-here>>
<<handlers-catalog>>
}
function appTemplate(root){
return html`
<header class="app-bar">
<<app-template-sync-indicator>>
<div class="app-bar-actions">
<<app-template-add-box-button>>
<<app-template-add-item-button>>
<<app-template-catalog-button>>
</div>
<<app-template-search-input>>
</header>
<<app-template-box-form>>
<<app-template-item-form>>
<<app-template-catalog-overlay>>
<<app-template-empty-state>>
<<app-template-box-list>>
<<app-template-unassigned-list>>
<<app-template-context-menu>>
`;
}
See Sync across devices for MergeableStore and startSync().
TinyBase persisters expose a status listener that fires on
every transition between idle, loading and saving; the boot
wires the idle return to a body attribute data-persist-seq
so the persistence test can wait on a counter rather
than on a debounce delay.
import { createMergeableStore } from 'tinybase';
import { createIndexedDbPersister } from 'tinybase/persisters/persister-indexed-db';
import { createWsSynchronizer } from 'tinybase/synchronizers/synchronizer-ws-client';
import { html, LitElement } from 'lit';
const store = createMergeableStore();
const persister = createIndexedDbPersister(store, 'organiser');
const appRootEl = document.querySelector('app-root');
function renderApp(){ appRootEl?.requestUpdate(); }
await persister.startAutoLoad();
store.addTablesListener(renderApp);
renderApp();
await persister.startAutoSave();
let _persistSeq = 0;
persister.addStatusListener((_, status) => {
if(status === 0) document.body.setAttribute(
'data-persist-seq', String(++_persistSeq));
});
document.body.setAttribute('data-app-ready', '1');
startSync();
Visual basics
Dark theme, pinned to CSS custom properties so the feature
chapters can reach for var(--muted) or var(--accent) by name.
:root{
--bg:#1b1d2e; --card:#262a40; --fg:#e8e8f0; --muted:#8a8ea5;
--accent:#f9a826;
}
Open the app
At first, with nothing in the store, the app opens to an empty state.
@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")
The empty state is a plain element: an emoji, the app’s name, a one-line hint.
class EmptyState extends LitElement {
createRenderRoot(){ return this; }
render(){
return html`
<div class="empty-state">
<div class="emoji">📦</div>
<h1>Organiser</h1>
<p>No boxes yet.</p>
</div>
`;
}
}
customElements.define('empty-state', EmptyState);
Rather than carry a visibility flag of its own, the element is put on screen only when the store has no box and no item yet — the template gates it the same way it gates the forms.
${!Object.keys(store.getTable('boxes')).length
&& !Object.keys(store.getTable('items')).length
? html`<empty-state></empty-state>` : ''}
Centred, muted, the emoji pushed to font-size 3 — at a glance:
Organiser
No boxes yet.
.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
The photo is the box: pick (or take) one, save. The photo carries everything I need to recognise it.
@testcase
def test_add_first_box(page):
"""The photo I just uploaded shows up in the list, and the
empty-state hint goes away."""
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": make_png(255, 0, 0),
}])
preview = page.locator(".box-photo-preview")
preview.wait_for(state="visible")
photo_src = preview.get_attribute("src")
page.get_by_role("button", name="Save").click()
assert page.locator(
f'button.box-photo:has(img[src="{photo_src}"])'
).is_visible(), \
"the photo I uploaded isn't showing in the list"
assert not page.get_by_text("No boxes yet").is_visible(), \
"empty-state still visible after add"
print(" PASS: add first box")
class AddBoxButton extends LitElement {
createRenderRoot(){ return this; }
render(){
return html`<sl-button @click=${() =>
this.dispatchEvent(new CustomEvent('open-box-form', {bubbles: true}))}>Add a box</sl-button>`;
}
}
customElements.define('add-box-button', AddBoxButton);
<add-box-button></add-box-button>
Now that the bar has its first occupant, time to style it. 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-actions{display:flex;gap:8px}
.add-form-actions sl-button{flex:1}
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
thumbnail uses object-fit:contain so the whole photo is
visible inside the square, letterboxed when the aspect ratio
doesn’t match — the alternative (cover) would crop the edges,
and the bit that identifies the box is often at the top of the
shot. To make the letterboxing read as deliberate rather than
as a glitch, the button background matches the page, not the
card. The same contain rule applies to the bigger preview
<img> inside the edit form, where the photo fills the form’s
width for a proper look before saving. The item form reuses
the same preview rule.
.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 box form is its own custom element. It always renders the form
when present in the DOM; the page emits it into the overlay layer
when ui.formOpen says box and removes it otherwise.
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.
modalShellTemplate wraps the form in an sl-dialog (defined
with the form helpers below). deleteBoxButton is the slot
Delete a box fills with a Delete button in edit mode.
class BoxForm extends LitElement {
static properties = { draft: {} };
createRenderRoot(){ return this; }
render(){
const {photo} = this.draft;
return modalShellTemplate(html`
<form class="add-form" @submit=${e => { e.preventDefault();
this.dispatchEvent(new CustomEvent('submit-box', {bubbles: true})); }}>
<label>Photo
<input type="file" accept="image/*" capture="environment"
@change=${e => this.dispatchEvent(new CustomEvent('box-photo-change',
{bubbles: true, detail: {file: e.target.files[0] || null}}))}>
</label>
${photo ? html`
<img class="box-photo-preview" src=${photo} alt="">
` : ''}
<div class="add-form-actions">
<sl-button type="submit" variant="primary">Save</sl-button>
<sl-button @click=${() =>
this.dispatchEvent(new CustomEvent('close-form', {bubbles: true}))}>Cancel</sl-button>
${deleteBoxButton(this)}
</div>
</form>
`);
}
}
customElements.define('box-form', BoxForm);
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 an sl-dialog. With
no-header and preventDefault on sl-request-close, there’s
no escape route except Save or Cancel — backdrop click and
Esc don’t close. Centring, sizing, backdrop and focus trap all
come from sl-dialog; the chapter doesn’t have to repeat that
mechanics anywhere.
function modalShellTemplate(form){
return html`
<sl-dialog
open
no-header
@sl-request-close=${e => e.preventDefault()}>
${form}
</sl-dialog>
`;
}
The form appears whenever ui.formOpen says box and is
absent otherwise.
${ui.formOpen === 'box' ? html`<box-form .draft=${ui.boxForm}></box-form>` : ''}
Tapping the button has to put the form on screen and keep what the user is picking until Save commits it. The renderer needs to know the form is up — a single flag, since only one form shows at a time. And the form needs somewhere to stash the current photo before save, with an id if it’s an edit. So:
formOpen: null,
boxForm: {photo: '', editingId: ''},
Opening the form is a single state mutation: seed an empty
draft and flip ui.formOpen. Closing it does the reverse and
wipes both form drafts so yesterday’s half-typed item name
doesn’t reappear next time. 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: '', editingId: ''},
});
}
function closeForm(){
setUI({
formOpen: null,
boxForm: {photo: '', 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(file){
if(!file){
setUI({boxForm: {...ui.boxForm, photo: ''}});
return;
}
const photo = await fileToThumbnail(file);
setUI({boxForm: {...ui.boxForm, photo}});
}
Save is the moment the draft becomes a real row. A brand-new
box lands at the end of the list (so the user sees it appear
where they expect); an edit keeps its existing position (so the
box doesn’t jump around mid-edit). The editingId bit of the
draft is what tells the two cases apart.
function onBoxFormSubmit(){
const {photo, 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, order});
closeForm();
}
The box form’s routes, wiring its events to the functions above:
on('open-box-form', () => openBoxForm());
on('close-form', () => closeForm());
on('box-photo-change', e => onBoxPhotoChange(e.detail.file));
on('submit-box', () => onBoxFormSubmit());
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 load 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.
class BoxList extends LitElement {
static properties = { rows: {}, items: {}, query: {} };
createRenderRoot(){ return this; }
render(){
const rows = this.rows;
const itemsById = this.items;
const query = (this.query || '').trim().toLowerCase();
const itemMatches = name => !query || name.toLowerCase().includes(query);
const sortedIds = Object.keys(rows).sort((a, b) =>
(rows[a].order ?? 0) - (rows[b].order ?? 0));
const ids = sortedIds.filter(id => {
if(!query) return true;
return Object.keys(itemsById).some(iid =>
itemsById[iid].boxId === id && itemMatches(itemsById[iid].name));
});
if(!ids.length) return '';
return html`
<ul class="boxes-list reorder-list" data-reorder="boxes" aria-label="Boxes">
${ids.map((id, idx) => html`
<box-card .boxId=${id} .idx=${idx} .row=${rows[id]}
.itemsById=${itemsById} .query=${query}></box-card>
`)}
</ul>
`;
}
}
customElements.define('box-list', BoxList);
The list reads the boxes table, the items table and the search query.
<box-list .rows=${store.getTable('boxes')}
.items=${store.getTable('items')}
.query=${ui.searchQuery}></box-list>
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).
Three later chapters extend this card:
boxCardItems(from Add an item) adds a chip row for the contained items.boxCardGrip(from Reorder boxes) adds the drag handle.?data-drop-target(from Drop an item into a box) marks the<li>as a target for the chip-drop gesture.
data-idx is carried for the reorder engine to read.
class BoxCard extends LitElement {
static properties = {
boxId: {}, idx: {}, row: {}, itemsById: {}, query: {},
};
createRenderRoot(){ return this; }
render(){
const { boxId: id, idx } = this;
return html`
<li class="box reorder-item"
data-box-id=${id}
data-idx=${idx}
?data-drop-target=${ui.dragEnabled}>
${boxCardBase(this)}
${boxCardItems(this)}
${boxCardGrip(this)}
</li>
`;
}
}
customElements.define('box-card', BoxCard);
The first contribution is 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.
function boxCardBase(card){
const { boxId: id, row } = card;
return html`
<button type="button" class="box-photo"
@click=${() => card.dispatchEvent(new CustomEvent('edit-box',
{bubbles: true, detail: {boxId: 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 thumbnail pipeline is
async, so before reading the new preview’s src the test waits
for it to differ from the old one — that’s how we know the new
photo has landed in ui.boxForm.photo and Save will pick it up.
@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": make_png(255, 0, 0),
}])
preview = page.locator(".box-photo-preview")
preview.wait_for(state="visible")
red_src = preview.get_attribute("src")
page.get_by_role("button", name="Save").click()
page.locator(f'button.box-photo:has(img[src="{red_src}"])').click()
page.get_by_label("Photo").set_input_files(files=[{
"name": "garage.png",
"mimeType": "image/png",
"buffer": make_png(0, 0, 255),
}])
preview.wait_for(state="visible")
page.wait_for_function(
f'() => document.querySelector(".box-photo-preview")?.src !== {red_src!r}'
)
blue_src = preview.get_attribute("src")
page.get_by_role("button", name="Save").click()
assert not page.locator(
f'button.box-photo:has(img[src="{red_src}"])'
).is_visible(), \
"old photo still visible — edit may have created a duplicate"
assert page.locator(
f'button.box-photo:has(img[src="{blue_src}"])'
).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):
"""A click on a box behind the open edit form doesn't reach it —
the form stays open and the targeted box doesn't take focus."""
clear_state(page)
srcs = []
for color in [(255, 0, 0), (0, 0, 255)]:
page.get_by_role("button", name="Add a box").click()
page.get_by_label("Photo").set_input_files(files=[{
"name": "box.png", "mimeType": "image/png", "buffer": make_png(*color)}])
preview = page.locator(".box-photo-preview")
preview.wait_for(state="visible")
srcs.append(preview.get_attribute("src"))
page.get_by_role("button", name="Save").click()
red_src, blue_src = srcs
page.locator(f'button.box-photo:has(img[src="{red_src}"])').click()
preview = page.locator(".box-photo-preview")
preview.wait_for(state="visible")
assert preview.get_attribute("src") == red_src, \
"edit form didn't open on the box I clicked"
blue_rect = page.locator(
f'button.box-photo:has(img[src="{blue_src}"])'
).bounding_box()
page.mouse.click(
blue_rect["x"] + blue_rect["width"] / 2,
blue_rect["y"] + blue_rect["height"] / 2,
)
assert preview.get_attribute("src") == red_src, \
"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. The eye always knows where to look, and a phone rotation just redraws into the new shape with nothing further to do. The centring check allows a 2-pixel slack so sub-pixel browser rounding doesn’t trip it.
Setup + the first check: at the phone viewport, the dialog is centred on both axes.
src = add_box(page, (255, 0, 0))
box_by_photo(page, src).click()
page.locator("form.add-form").wait_for(state="visible")
panel = page.get_by_role("dialog")
def assert_centered(vw, vh, slack=2):
rect = panel.bounding_box()
cx = rect["x"] + rect["width"] / 2
cy = rect["y"] + rect["height"] / 2
assert abs(cx - vw / 2) < slack, \
f"panel not horizontally centred: cx={cx}, vp_w={vw}"
assert abs(cy - vh / 2) < slack, \
f"panel not vertically centred: cy={cy}, vp_h={vh}"
assert rect["x"] >= 0 and rect["x"] + rect["width"] <= vw, \
f"panel off-screen horizontally: {rect}"
assert rect["y"] >= 0 and rect["y"] + rect["height"] <= vh, \
f"panel off-screen vertically: {rect}"
assert_centered(*PHONE_VIEWPORT.values())
After a rotation the dialog re-centres into the new viewport — CSS does the work, the test just waits for the layout to settle.
try:
page.set_viewport_size({"width": 800, "height": 400})
deadline = time.monotonic() + 1.5
while time.monotonic() < deadline:
r = panel.bounding_box()
if r and abs((r["x"] + r["width"] / 2) - 400) < 2 \
and abs((r["y"] + r["height"] / 2) - 200) < 2:
break
time.sleep(0.05)
assert_centered(800, 400)
finally:
page.set_viewport_size(PHONE_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 || '',
editingId: id,
},
});
}
Tapping a box card dispatches edit-box; its route:
on('edit-box', e => openBoxEdit(e.detail.boxId, e.detail.row));
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)
src = add_box(page, (255, 0, 0))
box_by_photo(page, src).click()
page.once("dialog", lambda d: d.accept())
page.get_by_role("button", name="Delete").click()
assert not box_by_photo(page, src).is_visible(), \
"thumbnail still listed"
print(" PASS: delete box")
The button slots into the action row already left open in the
form template (Add a box’s <<delete-box-button>> slot). 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.
function deleteBoxButton(form){
return ui.boxForm.editingId ? html`
<sl-button variant="danger" @click=${() =>
form.dispatchEvent(new CustomEvent('delete-box', {bubbles: true}))}>Delete</sl-button>
` : '';
}
When a box is deleted, its items go back to Unassigned rather than vanishing with it. Items first, then the box — the renderer fires once at the end of the transaction and shouldn’t see the intermediate state. The confirm message tells the user the items are safe.
function onBoxFormDelete(){
const {editingId} = ui.boxForm;
if(!editingId) return;
if(!confirm('Delete this box? Items inside go back to Unassigned.')) return;
store.transaction(() => {
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();
}
The Delete button’s route:
on('delete-box', () => onBoxFormDelete());
Reorder boxes
I want box order set by hand. Drag-to-reorder with a small right-edge grip — movable, doesn’t crowd the tap surface.
TinyBase iteration is stable across reloads but not across an
edit or a delete-and-recreate, so every box gets an explicit
order integer and the renderer sorts by it.
The drag rewrites every order cell in one pass (dense
indices 0, 1, 2, …) inside a store.transaction, so the
listener fires once instead of once per cell — during the drag
the pointer-engine’s geometry queries would otherwise chase a
list re-rendering under its feet.
I’m not writing the reorder 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)
red_src = add_box(page, (255, 0, 0))
add_box(page, (0, 255, 0))
blue_src = add_box(page, (0, 0, 255))
enable_drag(page)
grip_of(page, blue_src).drag_to(grip_of(page, red_src))
first_src = page.locator("li.box .box-photo-thumb").first.get_attribute("src")
assert first_src == blue_src, \
"the blue box should now be first after dragging it up"
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. The check allows a 4-pixel tolerance to absorb sub-pixel browser rounding.
@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)
red_src = add_box(page, (255, 0, 0))
add_box(page, (0, 255, 0))
enable_drag(page)
first_box = page.locator("li.box").nth(0)
src_rect = first_box.bounding_box()
grip = grip_of(page, red_src)
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.” It only
appears when ui.dragEnabled is on (see Drag mode is opt-in
for why drag is gated and how the flag flips).
function boxCardGrip(card){
return ui.dragEnabled ? html`
<button type="button" class="reorder-grip">
<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>
` : '';
}
data-idx lives on the <li> itself for the engine to read; it’s
carried in boxCardClass’s render as one of the host attributes.
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
Items are the point — screws, chargers, allen keys to find
next month. boxId starts empty so I can record an item
before deciding its box (cf. Drop an item into a box).
Unassigned items gather in an Unassigned section at the
bottom — invisible would mean forgotten.
@testcase
def test_add_first_item(page):
"""Adding an item lands it in the Unassigned section, and the empty-state hint goes away."""
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"
assert not page.get_by_text("No boxes yet").is_visible(), \
"empty-state hint still visible after first item"
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.
class AddItemButton extends LitElement {
createRenderRoot(){ return this; }
render(){
return html`<sl-button @click=${() =>
this.dispatchEvent(new CustomEvent('open-item-form', {bubbles: true}))}>Add an item</sl-button>`;
}
}
customElements.define('add-item-button', AddItemButton);
<add-item-button></add-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 are filled by later chapters:
itemNameErrorjust below the name field — Unique item names pours the duplicate-name alert.itemFormColourbetween the image preview and the action row — Items with background colour pours its picker and recent-colour swatches.deleteItemButtonalongside Save and Cancel — Delete an item pours its Delete button when the form is in edit mode.
class ItemForm extends LitElement {
static properties = { draft: {} };
createRenderRoot(){ return this; }
render(){ return modalShellTemplate(itemFormMarkup(this)); }
}
customElements.define('item-form', ItemForm);
function itemFormMarkup(form){
const {name, image} = form.draft;
return html`
<form class="add-form" @submit=${e => { e.preventDefault();
form.dispatchEvent(new CustomEvent('submit-item', {bubbles: true})); }}>
<sl-input label="Item name" placeholder="e.g. 3mm screw" required
.value=${name}
@sl-input=${e => form.dispatchEvent(new CustomEvent('item-name-input',
{bubbles: true, detail: {value: e.target.value}}))}></sl-input>
${itemNameError(form)}
<label>Image
<input type="file" accept="image/*" capture="environment"
@change=${e => form.dispatchEvent(new CustomEvent('item-image-change',
{bubbles: true, detail: {file: e.target.files[0] || null}}))}>
</label>
${image ? html`
<img class="item-image-preview" src=${image} alt="Image preview">
` : ''}
${itemFormColour(form)}
<div class="add-form-actions">
<sl-button type="submit" variant="primary">Save</sl-button>
<sl-button @click=${() =>
form.dispatchEvent(new CustomEvent('close-form', {bubbles: true}))}>Cancel</sl-button>
${deleteItemButton(form)}
</div>
</form>
`;
}
Same gating pattern as the box form.
${ui.formOpen === 'item' ? html`<item-form .draft=${ui.itemForm}></item-form>` : ''}
The item form’s state has to carry whatever the user is in the middle of entering. They type a name — that has to survive re-renders. They may pick an image for the chip — same. They may pick a background colour — same. If they’re editing instead of adding, the row id rides along so Save knows which row to overwrite. The duplicate-name guard (Unique item names) needs a flag to surface its complaint. And the Add item here shortcut (Add an item directly to a box) pre-fills the target box id. Six fields.
itemForm: {name: '', image: '', bgColor: '', editingId: '', nameError: false, boxId: ''},
Opening this form follows the same pattern as the box form,
with two extras: it closes any open context menu (see Drag
mode is opt-in) and accepts an optional boxId so Add an
item directly to a box can drop the item straight into a
specific container. closeForm already handles closing.
Edit an item will be the variant that pre-fills the draft.
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(value){
setUI({itemForm: {...ui.itemForm, name: 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(file){
if(!file){
setUI({itemForm: {...ui.itemForm, image: ''}});
return;
}
const image = await fileToThumbnail(file);
setUI({itemForm: {...ui.itemForm, image}});
}
Save commits the draft. The edit case must preserve whatever
box the item was already in — I don’t want renaming a screw to
yank it out of its drawer — while a new item picks up whatever
boxId was pre-filled when the form was opened. If the name’s
a duplicate, the form stays open and the alert lights up.
After a successful new-item save there are two endings,
depending on how the form was entered. Coming in via the
toolbar’s Add an item (no boxId) is a single-shot gesture:
Save closes the form. Coming in via the context menu’s Add
item here (with boxId) is a batch gesture: Save resets the
draft to empty while keeping the boxId, so the next name can
be typed straight away — see Add several items in a row. An
edit always closes the form.
function onItemFormSubmit(){
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 || '',
});
closeForm();
return;
}
store.setRow('items', crypto.randomUUID(), {
name, image, bgColor, boxId: boxId || '',
});
if(boxId){
setUI({itemForm: {name: '', image: '', bgColor: '', editingId: '',
nameError: false, boxId}});
} else {
closeForm();
}
}
The item form’s routes, wiring its events to the functions above:
on('open-item-form', e => openItemForm(e.detail || {}));
on('item-name-input', e => onItemNameInput(e.detail.value));
on('item-image-change', e => onItemImageChange(e.detail.file));
on('submit-item', () => onItemFormSubmit());
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 drop engine picks it
up as a source later. When the item has a background colour,
readableForeground (defined in Items with background colour)
picks black or white text to keep the name legible.
class ItemChip extends LitElement {
static properties = { itemId: {}, item: {} };
createRenderRoot(){ return this; }
render(){
const { itemId: id, item } = this;
return html`
<li class="item" data-item-id=${id} ?data-draggable=${ui.dragEnabled}
style=${item.bgColor ? `background:${item.bgColor};color:${readableForeground(item.bgColor)}` : ''}
@click=${() => this.dispatchEvent(new CustomEvent('edit-item',
{bubbles: true, detail: {itemId: id, item}}))}>
${item.image ? html`<img class="item-thumb" src=${item.image} alt=${item.name}>` : ''}
<span class="item-name">${item.name}</span>
</li>
`;
}
}
customElements.define('item-chip', ItemChip);
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 lives 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.
class UnassignedList extends LitElement {
static properties = { items: {}, query: {} };
createRenderRoot(){ return this; }
render(){
const itemsTable = this.items;
const query = (this.query || '').trim().toLowerCase();
const unassignedIds = Object.keys(itemsTable)
.filter(id => {
if(itemsTable[id].boxId) return false;
return !query || itemsTable[id].name.toLowerCase().includes(query);
})
.sort((a, b) => itemsTable[a].name.localeCompare(itemsTable[b].name));
if(!unassignedIds.length) return '';
return html`
<section class="unassigned" ?data-drop-target=${ui.dragEnabled}>
<h2>Unassigned</h2>
<ul class="items-list">
${unassignedIds.map(id => html`
<item-chip .itemId=${id} .item=${itemsTable[id]}></item-chip>
`)}
</ul>
</section>
`;
}
}
customElements.define('unassigned-list', UnassignedList);
We need a component that uses the items table to pick out
the rows whose boxId is empty, and the search query to
narrow them when the user is searching.
<unassigned-list .items=${store.getTable('items')}
.query=${ui.searchQuery}></unassigned-list>
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.
function boxCardItems(card){
const { boxId: id, itemsById } = card;
const query = (card.query || '').trim().toLowerCase();
const itemMatches = name => !query || name.toLowerCase().includes(query);
return html`
<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 => html`
<item-chip .itemId=${iid} .item=${itemsById[iid]}></item-chip>
`)}
</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)
box_src = add_box(page, (255, 0, 0))
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(box_by_photo(page, box_src))
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. The
submit handler reads the existing row’s boxId and writes it
back unchanged.
@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,
},
});
}
Tapping a chip dispatches edit-item; its route:
on('edit-item', e => openItemEdit(e.detail.itemId, e.detail.item));
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.
function deleteItemButton(form){
return ui.itemForm.editingId ? html`
<sl-button variant="danger" @click=${() =>
form.dispatchEvent(new CustomEvent('delete-item', {bubbles: true}))}>Delete</sl-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();
}
The Delete button’s route:
on('delete-item', () => onItemFormDelete());
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": make_png(255, 0, 0),
}])
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
Beyond the photo, I want a second scan axis: a colour tint per chip. Everything electrical blue, every fastener red — the page reads as a colour-coded legend at a glance.
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")
What this chapter adds is the part the user touches: the picker, the swatch shortcuts, the readability rule, and the styling to match. The schema cell, form wiring and chip render were already in place.
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 || ''}});
}
Every colour choice arrives as one item-colour-pick event,
routed through that setter:
on('item-colour-pick', e => setItemFormColour(e.detail.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.
function itemFormColour(form){
return html`
<label>Background colour
<input type="color" .value=${ui.itemForm.bgColor || '#000000'}
@input=${e => form.dispatchEvent(new CustomEvent('item-colour-pick',
{bubbles: true, detail: {value: 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=${() => form.dispatchEvent(new CustomEvent('item-colour-pick',
{bubbles: true, detail: {value: ''}}))}></button>
${usedItemColours().map(c => html`
<button type="button" class="colour-swatch"
style=${`background:${c}`}
aria-label="Use colour ${c}"
@click=${() => form.dispatchEvent(new CustomEvent('item-colour-pick',
{bubbles: true, detail: {value: 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.
function itemNameError(form){
return 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"
assert page.get_by_label("Item name").is_visible(), \
"form closed even though Save was refused"
assert page.get_by_text("Hammer", exact=True).count() == 1, \
"duplicate Hammer chip created despite the error"
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)
box_src = add_box(page, (255, 0, 0))
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(box_by_photo(page, box_src))
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; and <box-card>’s render already emits
?data-drop-target=${ui.dragEnabled} on the <li> so the box is
a drop target when drag mode is on.
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. The test opens the edit
form by tapping the photo button specifically — tapping the
box li centroid would land on the nested Hammer chip and
route to item-edit instead.
@testcase
def test_delete_box_with_items(page):
"""Deleting a box with items inside returns the items to Unassigned."""
clear_state(page)
box_src = add_box(page, (255, 0, 0))
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 = box_by_photo(page, box_src)
enable_drag(page)
page.get_by_text("Hammer").drag_to(photoBtn)
photoBtn.click()
page.once("dialog", lambda d: d.accept())
page.get_by_role("button", name="Delete").click()
assert not box_by_photo(page, box_src).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.
We need something to remember the state of the drag mode — a flag the renderer reads to decide whether to emit grips and chip drag-handles. Starts off.
dragEnabled: 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.
We also need somewhere to remember the menu itself — where it
was opened (so it paints at the gesture’s spot), and if the
gesture landed on a box card the id of that box too, since
Add an item directly to a box unlocks an extra menu entry in
that case. null when no menu is showing.
contextMenu: null,
class ContextMenu extends LitElement {
static properties = { pos: {} };
createRenderRoot(){ return this; }
render(){
const pos = this.pos;
if(!pos) return '';
return html`
<sl-menu class="context-menu"
style="left:${pos.x}px; top:${pos.y}px">
${contextMenuAddItemEntry(this, pos)}
${contextMenuToggleEntry(this)}
</sl-menu>
`;
}
}
customElements.define('context-menu', ContextMenu);
And now, the context menu.
<context-menu .pos=${ui.contextMenu}></context-menu>
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.
function contextMenuToggleEntry(menu){
return html`
<sl-menu-item @click=${() =>
menu.dispatchEvent(new CustomEvent('toggle-drag', {bubbles: true}))}>
${ui.dragEnabled ? 'Done' : 'Rearrange'}
</sl-menu-item>
`;
}
Its route flips the flag and dismisses the menu in one mutation:
on('toggle-drag', () => setUI({dragEnabled: !ui.dragEnabled, contextMenu: null}));
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. One dispatch subtlety: a chip carries its
own identity, so a contextmenu on a chip doesn’t inherit the
parent box’s “add item here” entry.
document.addEventListener('contextmenu', e => {
if (e.target.closest('input, textarea, select, .context-menu')) return;
e.preventDefault();
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;min-width:140px}
Four affordances pour into the shared drop and reorder engines:
- the chip’s
data-draggable(from The chip); data-drop-targeton the Unassigned section;data-drop-targeton each box card (from Drop an item into a box);- the box’s reorder grip (from Reorder boxes).
Each 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 is three behaviours:
- drag is off by default;
- Rearrange enables it;
- Done switches it back off.
Drag without Rearrange is a no-op.
page.get_by_text("Hammer").drag_to(box_by_photo(page, box_src))
assert page.get_by_role("heading", name="Unassigned").is_visible(), \
"Hammer moved into the box with drag disabled"
After Rearrange, the same drag now lands.
enable_drag(page)
page.get_by_text("Hammer").drag_to(box_by_photo(page, box_src))
assert not page.get_by_role("heading", name="Unassigned").is_visible(), \
"Hammer didn't move after enabling drag"
Re-opening the menu now offers Done; selecting it returns to read mode, and the next contextmenu offers Rearrange again.
page.locator("main").click(button="right", position={"x": 5, "y": 5})
page.get_by_role("menuitem", name="Done").click()
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"
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.
function contextMenuAddItemEntry(menu, pos){
return pos.boxId ? html`
<sl-menu-item @click=${() => menu.dispatchEvent(new CustomEvent('add-item-here',
{bubbles: true, detail: {boxId: pos.boxId}}))}>
Add item here
</sl-menu-item>
` : '';
}
Its route opens the item form with the box pre-filled:
on('add-item-here', e => openItemForm({boxId: e.detail.boxId}));
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)
box_src = add_box(page, (255, 0, 0))
box_by_photo(page, box_src).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()
page.get_by_role("button", name="Cancel").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")
Add several items in a row
When I’m tagging the contents of a box I’ve just opened, I’m
usually doing it in a batch — screws, screwdriver, a few drill
bits, all in the next thirty seconds. Closing the form after
every Save and forcing me to re-trigger the context menu turns
that batch into a sequence of three-tap workflows. Instead,
when the form was opened through Add an item directly to a
box — i.e. with a pre-filled boxId — Save keeps the form
open and resets the draft, so the next name can be typed
straight away. The boxId survives the reset so subsequent
items land in the same box. Cancel ends the batch.
The toolbar’s Add an item stays single-shot: with no boxId
there’s no destination to keep the batch pointed at, and the
more common case there is “add one unassigned item, then go do
something else”. Edits are always single-shot. The branching
lives in The form’s submit handler.
@testcase
def test_add_several_items_in_a_row(page):
"""Saving an item via 'Add item here' clears the form and leaves
it open, ready for the next item in the same box."""
clear_state(page)
box_src = add_box(page, (255, 0, 0))
box_by_photo(page, box_src).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()
assert page.get_by_label("Item name").is_visible(), \
"form closed after Save — batch flow broken"
assert page.get_by_label("Item name").input_value() == "", \
"form didn't reset after Save"
page.get_by_label("Item name").fill("Wrench")
page.get_by_role("button", name="Save").click()
page.get_by_role("button", name="Cancel").click()
chips_in_box = page.locator(".boxes-list .items-in-box .item-name").all_inner_texts()
assert "Hammer" in chips_in_box and "Wrench" in chips_in_box, \
f"both items should be inside the same box: {chips_in_box}"
print(" PASS: add several items in a row")
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 box has to remember what’s currently typed so the
filter holds across re-renders. One string in ui.
searchQuery: '',
The input itself is a Shoelace <sl-input> bound two-way to
ui.searchQuery via setUI. Each keystroke triggers a
re-render; the box list and Unassigned section both read
ui.searchQuery and filter accordingly — machinery wired into
those templates ahead of time.
<sl-input type="search" placeholder="Search…" aria-label="Search" clearable
.value=${ui.searchQuery}
@sl-input=${e => setUI({searchQuery: e.target.value})}></sl-input>
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)
workshop_src = add_box(page, (255, 0, 0))
kitchen_src = add_box(page, (0, 0, 255))
enable_drag(page)
for item_name, target_src in [("Hammer", workshop_src), ("Saw", kitchen_src)]:
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(box_by_photo(page, target_src))
page.get_by_role("searchbox").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 box_by_photo(page, workshop_src).is_visible(), \
"workshop thumbnail hidden though it owns Hammer"
assert not box_by_photo(page, kitchen_src).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)
box_src = add_box(page, (255, 0, 0))
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_role("searchbox").fill("nonexistent")
assert not page.get_by_text("Saw").is_visible()
assert not box_by_photo(page, box_src).is_visible()
page.get_by_role("searchbox").fill("")
assert page.get_by_text("Saw").is_visible(), \
"Saw didn't come back when search was cleared"
assert box_by_photo(page, box_src).is_visible(), \
"box 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.
We need something to remember whether the catalog is on screen right now — a flag the renderer reads to decide whether to paint the overlay. False by default.
catalogOpen: false,
class CatalogButton extends LitElement {
createRenderRoot(){ return this; }
render(){
return html`<sl-button @click=${() =>
this.dispatchEvent(new CustomEvent('open-catalog', {bubbles: true}))}>Catalog</sl-button>`;
}
}
customElements.define('catalog-button', CatalogButton);
<catalog-button></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. For rapid
re-jumps to the same box, the previous animation might not
have ended yet, so the code strips the class, forces a
reflow with void el.offsetWidth, then re-adds it — without
the reflow the browser would coalesce remove+add into a no-op.
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');
void el.offsetWidth;
el.classList.add('spotlight');
el.addEventListener('animationend',
() => el.classList.remove('spotlight'),
{once: true});
}
The catalog’s three routes: the app-bar button opens it, the dialog’s close mechanics dismiss it, and a chip tap jumps to its box.
on('open-catalog', () => setUI({catalogOpen: true}));
on('close-catalog', () => setUI({catalogOpen: false}));
on('catalog-jump', e => jumpToBox(e.detail.boxId));
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.
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”.
The catalog is another sl-dialog, but unlike the form modals
it’s cancellable: the dialog’s default close mechanics —
backdrop click, Escape, the close button in the header — are
all allowed, all routed to flipping ui.catalogOpen back to
false.
class CatalogOverlay extends LitElement {
static properties = { open: {}, items: {}, query: {} };
createRenderRoot(){ return this; }
render(){
if(!this.open) return '';
const itemsTable = this.items;
const query = (this.query || '').trim().toLowerCase();
const ids = Object.keys(itemsTable)
.filter(id => !query ||
itemsTable[id].name.toLowerCase().includes(query))
.sort((a, b) =>
itemsTable[a].name.localeCompare(itemsTable[b].name));
return html`
<sl-dialog label="All items" open
style="--width: 480px;"
@sl-request-close=${() =>
this.dispatchEvent(new CustomEvent('close-catalog', {bubbles: true}))}>
<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=${() => this.dispatchEvent(new CustomEvent('catalog-jump',
{bubbles: true, detail: {boxId: item.boxId}}))}>
</button>
`;
})}
</section>
</sl-dialog>
`;
}
}
customElements.define('catalog-overlay', CatalogOverlay);
We need a component that lists every item, narrowed to the same search query as the box list.
<catalog-overlay .open=${ui.catalogOpen}
.items=${store.getTable('items')}
.query=${ui.searchQuery}></catalog-overlay>
The chip inside the dialog is visually quiet — a thin border, modest padding — to keep a long list scannable.
.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)}
Three behaviours to pin:
- the catalog is closed by default and opens via the app-bar button;
- each chip carries the item’s colour;
- clicking one closes the catalog while jumping to (and briefly highlighting) the destination box.
Setup + first check: build twenty boxes so the target sits off-screen, add a red-tinted item into the last one, confirm the catalog is closed by default, then open it and see the chip carry the right colour.
target_src = None
for i in range(20):
target_src = add_box(page, (10 + i * 12, 100, 200))
box_by_photo(page, target_src).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()
page.get_by_role("button", name="Cancel").click()
assert page.get_by_role("dialog", name="Catalog").count() == 0, \
"catalog visible before toggle"
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}"
Clicking the chip closes the catalog and scrolls the target box into view.
page.evaluate("window.scrollTo(0, 0)")
chip.click()
assert page.get_by_role("dialog", name="Catalog").count() == 0, \
"catalog still open after jumping"
page.wait_for_function(
f"""() => {{
const img = document.querySelector('img.box-photo-thumb[src="{target_src}"]');
if (!img) return false;
const r = img.closest('button.box-photo').getBoundingClientRect();
return r.top >= 0 && r.bottom <= window.innerHeight;
}}""",
timeout=2000,
)
The destination card briefly carries .spotlight so the eye
catches it.
page.wait_for_function(
f"""() => {{
const img = document.querySelector('img.box-photo-thumb[src="{target_src}"]');
return img && img.closest('li.box').classList.contains('spotlight');
}}""",
timeout=2000,
)
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.
TinyBase ships an IndexedDB persister, already wired in Initial
load: every store mutation mirrors to a browser-local database
called organiser, and on the next boot startAutoLoad reads
it back before the first render. This chapter is just the
regression test — a box added in one session is still there in
the next.
The race between Save and reload is settled by the
data-persist-seq signal from Initial load: a status
listener bumps the attribute every time the persister leaves
the saving state, and the test waits on that counter rather
than guessing 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')")
box_src = add_box(page, (255, 0, 0))
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 box_by_photo(page, box_src).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.
That label has to read from somewhere the indicator can
subscribe to, so the connection state lives as a ui field
the renderer picks up like any other.
syncStatus: 'off',
class SyncIndicator extends LitElement {
static properties = { status: {} };
createRenderRoot(){ return this; }
render(){
return html`<span class="sync-status" data-status=${this.status} role="status">Sync ${this.status}</span>`;
}
}
customElements.define('sync-indicator', SyncIndicator);
The indicator is bound to ui.syncStatus and dropped into the
app-bar by the skeleton in Initial load.
<sync-indicator .status=${ui.syncStatus}></sync-indicator>
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")
box_src = add_box(pageA, (255, 0, 0))
box_by_photo(pageB, box_src).wait_for(state="visible", timeout=10000)
print(" PASS: sync propagation")
Three pieces wire sync into the app: an indicator setter, a WebSocket open-promise, and a connect loop.
The setter mirrors the connection state into the UI flag the sync indicator reads.
function setSyncStatus(s){
setUI({syncStatus: s});
}
The open-promise resolves on the first open event and rejects
on the first error — the standard one-shot WebSocket dance.
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});
});
}
The loop is what keeps sync alive: consume the one-shot URL
from the query and persist it to localStorage, then forever,
in the background, try 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.
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 to write by hand. TinyBase already gives me the protocol; what I write is the relay around it.
Why Bun. 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 is a matter
of getting the sync URL onto it. The ?sync_url query
parameter pre-populates it, so the first time I open the app on
a 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.
PWA shell
I want to add the app to my phone’s home screen and open it like a native one — 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-nil' },
];
const ASSETS = ['./', './index.html', './app.js', './manifest.json'];
nil
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
covers the page while the boot finishes and disappears the
moment data-app-ready flips on the body — the same signal
the tests wait on.
<div id="loading"><sl-spinner style="font-size:44px;--indicator-color:var(--accent)"></sl-spinner></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}
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">nil</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
The per-feature Playwright tests need a runner. Two rules shape it.
The first is that tests reach for the user-facing surface first: visible text, accessible roles, labelled inputs, placeholders. Class selectors and DOM traversal are kept for the cases where the test must probe what the user can see but cannot name — drag clones, the spotlight animation marker, layout sanity checks. No reaching into the store, no test-only hooks. The discipline keeps the suite a contract about what the user experiences, with a few small windows into internals the user is also experiencing without a name for.
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.
nil
nil
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()
Most tests create a box and then need to locate it again — by the
photo, not by some out-of-band handle, because the photo is the
box (cf. Add a box). add_box drives the upload-and-save
flow and hands back the resulting thumbnail’s data URL; box_by_photo
turns that URL into a Playwright locator. Tests then read like a
user pointing at a specific tile.
def add_box(page, color):
page.get_by_role("button", name="Add a box").click()
page.get_by_label("Photo").set_input_files(files=[{
"name": "box.png", "mimeType": "image/png",
"buffer": make_png(*color),
}])
preview = page.locator(".box-photo-preview")
preview.wait_for(state="visible")
src = preview.get_attribute("src")
page.get_by_role("button", name="Save").click()
box_by_photo(page, src).wait_for(state="visible")
return src
def box_by_photo(page, src):
return page.locator(f'button.box-photo:has(img[src="{src}"])')
def grip_of(page, src):
return page.locator(f'li.box:has(img[src="{src}"]) .reorder-grip')
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.
nil
The readiness probe is shared — _wait_ws_handshake (in the harness helpers) drives
a real WebSocket upgrade and waits for the 101, since Bun binds the port a beat before
the upgrade handler is wired, so an early new WebSocket(...) would otherwise hang.
nil
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.
Build distinct fixtures. The photo is the box (cf. Add a
box), so each box in a test needs a distinct photo or the
test can’t tell one box from another by recognising what was
uploaded. A tiny make_png(r, g, b) builder produces a 1×1 RGB
PNG per call: small, deterministic, distinct by colour, and
small 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, struct, sys, uuid, zlib
nil
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}
def make_png(r, g, b):
def chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data +
struct.pack(">I", zlib.crc32(tag + data)))
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
idat = zlib.compress(bytes([0, r, g, b]))
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
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.
nil
Conclusion
There is no bridge between rendering and sync. The renderer
subscribes to the store via one addTablesListener, and the
client-side sync engine (reconnect loop + status indicator)
totals about 53 lines. The bridges in the previous attempts
ran 45 to 225 lines (see choice of technology) — code whose
only job was to keep two engines in step. Here, there is no
second engine.