Konubinix' opinionated web of thoughts

Trigger List Pwa

Fleeting

choice of technology

This is my latest try at local-first. What I want hasn’t shifted since the first attempt: locality of behaviour, fast iterations in literate programming, and a stack that starts with no build and no boilerplate. What is new is that sharing between phones is the whole point — so the sync story decides the stack.

A trigger list is the simplest data shape there is: a bag of independent checkboxes. “Aurélie ticked sunscreen at 14:03” — each box is an isolated last-write-wins, no sequence and no text to merge.

cr-sqlite is the tempting candidate: SQLite in WASM with a CRDT extension, and a reactive feed so the database itself is the single source the renderer reads — an excellent local layer. But its browser sync is the wrong foundation for a shared app. Its supported client runs the database inside a Web Worker; the inline, no-worker path can’t tell its own applied changes from the app’s writes — @vlcn.io/ws-browserdb’s own source says to “force sync to run in a worker in the browser” because “we need a reliable way to filter out our own events if we’re not in a worker.” A sync layer that demands a worker or ships an unsolved echo is the wrong thing to build a shared list on, so cr-sqlite is out.

Loro is a CRDT shaped like the sync I already trust. It takes no view on transport: each peer exports its changes as bytes and imports the others’, and Loro merges them. That is the relay-gated-by-my-authorizationserver pattern I have run three times — ywebsocket, automergesync, tinybasesync — main-thread, no worker. And Loro tags each change’s origin, so a peer’s imported change is distinguishable from a local write — the thing cr-sqlite’s inline path lacked. Concurrent edits converge: tick “sunscreen” on one doc and “tent” on another, exchange both ways, and both settle on the same set. That convergence is the whole feature, and it is a few lines over a socket.

For rendering, µhtml — a few kilobytes of html`…` templates, no compiler and no build. I change render engine every app on purpose: the slider in Alpine, the frise in Preact, the photos/videos app in Solid (the photos/videos organiser) — this one’s turn is µhtml. It keeps no state of its own; it paints whatever it is handed.

So Loro being a CRDT primitive rather than a reactive store costs no bridge here: since µhtml keeps no state, the doc stays the single source and µhtml repaints from it on each subscribe — the same shape cr-sqlite’s reactive feed had. Two real costs remain. Loro has no built-in persistence, where cr-sqlite’s IndexedDB VFS gave it for free, so a snapshot is saved to IndexedDB on each change and loaded on boot. And its engine is WASM, loaded by dynamic import so it does not hold up first paint, the way Condorcet does for Automerge.

Cross-language is preserved, by a different route than SQL would have taken: not a Python import reading a SQLite file, but Loro’s Rust core with a Python binding, so a non-JS peer can read the same document.

Open the app

A fresh trigger list is empty — nothing is built in; you fill it yourself. By default the screen just shows what you have added so far, ready to use; while it is empty it says so, rather than showing a blank page. What you add is yours, kept on the device.

@testcase
def test_empty_by_default(page):
    """A fresh load has no items — nothing is seeded."""
    clear_state(page)
    assert page.get_by_text("Rien à prendre pour l'instant").is_visible()
    assert page.get_by_role("listitem").count() == 0
    print("  PASS: empty by default")

The list has two modes, and which acts belong to which is decided by what a packing session must never suffer. Adding is not one of those: remembering a thing on the way out of the door is the commonest act there is, and it costs nothing if it goes wrong. Renaming, removing and rearranging are the opposite — a stray thumb there quietly ruins a list you rely on. So the default mode carries ticking and one add field, and nothing that could damage what is already written; the Modifier button opens edit mode where the rest lives, and Terminé closes it again.

@testcase
def test_only_ticking_by_default(page):
    """Default mode ticks and adds, and offers nothing that rewrites the list."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    page.get_by_role("button", name="Terminé").click()
    assert page.get_by_placeholder("Ajouter une chose").is_visible()         # adding stays
    assert page.get_by_role("button", name="Nouvelle chose").count() == 0    # the per-card + does not
    assert page.get_by_role("button", name="Renommer").count() == 0
    assert page.get_by_role("button", name="Supprimer").count() == 0
    box = page.get_by_role("checkbox", name="tente")
    box.click()
    expect(box).to_be_checked()
    print("  PASS: only ticking by default")

Edit mode is easy to forget you are in, so it colours the page: the background shifts while editing and returns to plain when you are done, a standing reminder that you are curating — renaming, removing, rearranging — not just ticking.

@testcase
def test_edit_mode_tints_the_page(page):
    """Entering edit mode changes the page background; leaving it restores it."""
    clear_state(page)
    plain = page.evaluate("getComputedStyle(document.body).backgroundColor")
    edit(page)
    editing = page.evaluate("getComputedStyle(document.body).backgroundColor")
    assert editing != plain, (plain, editing)
    page.get_by_role("button", name="Terminé").click()
    assert page.evaluate("getComputedStyle(document.body).backgroundColor") == plain
    print("  PASS: edit mode tints the page")

The same split governs the row itself: in the default mode a thing shows no selection box, so nothing is gathered up and no bulk change can be set off by a thumb that only meant to tick.

@testcase
def test_no_select_by_default(page):
    """Default mode shows no selection boxes — a bulk change is an edit-mode act."""
    clear_state(page)
    edit(page)
    add_tag(page, "soleil")
    add_thing(page, "lunettes", "soleil")
    page.get_by_role("button", name="Terminé").click()
    assert page.get_by_role("checkbox", name="Sélectionner").count() == 0
    print("  PASS: no select by default")

The toolbar sticks to the top, so on a long list the way back into edit mode is always in reach — scroll to the bottom and the toggle is still there.

@testcase
def test_toolbar_sticks_on_scroll(page):
    """The mode toggle stays on screen when a long list is scrolled."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for i in range(20):
        add_thing(page, f"article {i}", "sac")
    page.get_by_role("button", name="Terminé").click()
    page.mouse.wheel(0, 6000)
    page.wait_for_timeout(100)
    box = page.get_by_role("button", name="Modifier", exact=True).bounding_box()
    assert box is not None and 0 <= box["y"] < PHONE_VIEWPORT["height"]
    print("  PASS: toolbar sticks on scroll")

The toolbar always carries an undo and a redo: they walk back and forth over this device’s own changes — Loro tracks them, so they never touch what the other phone did. Undo an accidental add and it is gone; redo brings it back.

@testcase
def test_undo_and_redo(page):
    """Undo reverts this device's last change; redo restores it."""
    clear_state(page)
    edit(page)
    add_thing(page, "tente")
    assert page.get_by_text("tente").count() == 1
    page.get_by_role("button", name="Défaire").click()
    assert page.get_by_text("tente").count() == 0
    page.get_by_role("button", name="Refaire").click()
    assert page.get_by_text("tente").count() == 1
    print("  PASS: undo and redo")

Because a tick is a change like any other, undo reaches it too — and ticking is what the default mode is for, so undo stays on hand there, no edit mode needed.

@testcase
def test_undo_a_tick_in_view_mode(page):
    """A tick can be undone from the default (view) mode."""
    clear_state(page)
    edit(page)
    add_thing(page, "tente")
    page.get_by_role("button", name="Terminé").click()
    box = page.get_by_role("checkbox", name="tente")
    box.click()
    expect(box).to_be_checked()
    page.get_by_role("button", name="Défaire").click()
    expect(page.get_by_role("checkbox", name="tente")).not_to_be_checked()
    print("  PASS: undo a tick in view mode")

The cheapest list is the one nobody planned. You are at the door, three things must not be forgotten, and every ceremony — naming the list, choosing where to file it, crossing into a mode — costs more than the list is worth. So the main screen carries one field, always in reach, and Enter is the whole gesture: type, Enter, type, Enter, and you are done. The field keeps its focus between two things, so the phone’s keyboard never drops in the middle of a run.

@testcase
def test_quick_list(page):
    """Three things onto the list with no mode, no name and no group — just typing."""
    clear_state(page)
    field = page.get_by_placeholder("Ajouter une chose")
    for thing in ("pain", "lait", "œufs"):
        field.fill(thing)
        field.press("Enter")
    for thing in ("pain", "lait", "œufs"):
        assert page.get_by_text(thing).is_visible()
    assert page.get_by_role("button", name="Modifier", exact=True).is_visible()   # never left the default mode
    print("  PASS: quick list")

What makes that run of Enters possible is that the field answers a thumb at all: a box that only takes a value handed to it from outside is a box nobody can type into. So it must accumulate real keystrokes, one after another, and hold what they spell.

@testcase
def test_typing_fills_the_field(page):
    """Real keystrokes accumulate in the field (not just programmatic fill)."""
    clear_state(page)
    field = page.get_by_placeholder("Ajouter une chose")
    field.click()
    field.press_sequentially("passeport")
    assert field.input_value() == "passeport"
    print("  PASS: typing fills the field")

And it outlives a reload — the doc is persisted, so what you added is still there next time.

@testcase
def test_item_persists(page):
    """An added item survives a reload."""
    clear_state(page)
    add_thing(page, "passeport")
    page.wait_for_timeout(300)  # let the snapshot flush to IndexedDB
    page.reload()
    page.wait_for_selector("#app > *")
    assert page.get_by_text("passeport").is_visible()
    print("  PASS: item persists")

A scratch pile is enough for one errand, but the things you pack come back trip after trip, and what makes them findable again is what they have in common — soleil, piscine, ski. So a thing carries tags, as many at once as suit it, and the screen groups by them: one card per tag, its things listed under it. All the cards together are your catalog — everything you own that is worth remembering, whether or not any trip is in view. A thing’s id is its label, normalized, so the same label is always the same one thing (and, as a consequence you accept, you cannot keep two different things that share a label — write them apart). The things carrying nothing yet stay together under Sans étiquette, which is where the quick list you just typed has been sitting all along.

@testcase
def test_add_tag(page):
    """Creating a tag makes its card heading appear."""
    clear_state(page)
    edit(page)
    add_tag(page, "soleil")
    assert page.get_by_role("heading", name="soleil").is_visible()
    print("  PASS: add tag")

Adding a thing loose and tagging it after is two acts where one would do, so each tag card carries its own + in edit mode: the thing created there comes out already carrying that tag.

@testcase
def test_tag_add_button(page):
    """A tag's + opens an inline field whose thing comes out carrying that tag."""
    clear_state(page)
    edit(page)
    add_tag(page, "plage")
    plage = page.locator("section").filter(has=page.get_by_role("heading", name="plage"))
    plage.get_by_role("button", name="Nouvelle chose").click()
    plage.get_by_placeholder("Nouvelle chose").fill("crème")
    plage.get_by_role("button", name="Créer la chose").click()
    assert plage.get_by_text("crème").is_visible()
    print("  PASS: tag add button")

That + lives in edit mode, but crossing into edit mode just to jot down one thing you forgot is a lot of ceremony for a small act. So the card title carries the gesture too: hold it a moment — a long press, past half a second — in the default mode and the same add field opens right under it, a thing dropped in without ever leaving packing.

@testcase
def test_longpress_tag_adds_in_view_mode(page):
    """A long press on a tag's title opens its add field with no trip into edit mode."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    page.get_by_role("button", name="Terminé").click()                     # default mode
    assert page.get_by_role("button", name="Nouvelle chose").count() == 0  # no add + here
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    sac.get_by_role("button", name="sac").click(delay=700)                 # hold the title
    field = sac.get_by_placeholder("Nouvelle chose")
    field.fill("corde")
    field.press("Enter")
    assert sac.get_by_text("corde").is_visible()
    print("  PASS: longpress tag adds in view mode")

Opened this way, the field stands alone — none of edit mode’s chrome around it to hint how to back out. So it takes the dismissal every floating thing here takes: a press anywhere outside it closes it, the same instinct as tapping the dimmed area around a sheet.

@testcase
def test_tap_outside_closes_tag_add(page):
    """A press outside the long-press add field dismisses it."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    sac.get_by_role("button", name="sac").click(delay=700)   # long press opens the field
    assert sac.get_by_placeholder("Nouvelle chose").is_visible()
    page.get_by_placeholder("Ajouter une chose").click()      # a press outside it
    assert page.get_by_placeholder("Nouvelle chose").count() == 0
    print("  PASS: tap outside closes tag add")

A thing carrying two tags shows under both — the same thing, listed twice, because it is genuinely wanted in both places.

@testcase
def test_item_under_two_tags(page):
    """A thing given two tags appears under both cards."""
    clear_state(page)
    edit(page)
    for name in ("soleil", "ski"):
        add_tag(page, name)
    for tag in ("soleil", "ski"):
        add_thing(page, "crème solaire", tag)
    assert page.get_by_text("crème solaire").count() == 2
    print("  PASS: item under two tags")

Two phones can build the list apart, then meet: each gives the same thing a different tag while offline, and when they sync the thing must come out carrying both — a tag is its own fact, not a field one side overwrites.

@testcase
def test_membership_converges(page):
    """Two phones offline each give the same thing a different tag;
    after they sync it carries both — neither tag is lost."""
    sync = require_sync_server()
    browser = page.context.browser
    with two_contexts(browser) as (ca, cb):
        a = open_app(ca, BASE_URL)                        # offline, no sync_url
        edit(a)
        add_tag(a, "soleil")
        add_thing(a, "crème solaire", "soleil")

        b = open_app(cb, BASE_URL)                        # offline too
        edit(b)
        add_tag(b, "ski")
        add_thing(b, "crème solaire", "ski")

        a.wait_for_timeout(300); b.wait_for_timeout(300)  # each persists its divergent doc

        room = f"{sync}/converge"                          # now they meet and exchange
        a.goto(f"{BASE_URL}?sync_url={room}"); a.wait_for_selector("#app > *")
        b.goto(f"{BASE_URL}?sync_url={room}"); b.wait_for_selector("#app > *")

        expect(a.get_by_role("heading", name="ski")).to_be_visible(timeout=8000)
        expect(a.get_by_text("crème solaire")).to_have_count(2, timeout=8000)
        print("  PASS: membership converges")

Where a thing belongs shifts as you live with the list: chargeur turns out to belong with voyage as well, lunettes has nothing to do with ski after all. Held in a container that is a removal and an insertion — pick the thing, name a verb, choose a destination, four gestures for a change of mind. A tag is not a place but a state, so it is one gesture to flip: hold the thing, and every tag comes up as a chip along its row, the ones it carries filled in and the rest hollow. Tap a hollow one and the thing carries it.

@testcase
def test_chip_tags_a_thing(page):
    """Holding a thing offers every tag as a chip; a hollow one tapped is now carried."""
    clear_state(page)
    edit(page)
    for name in ("soleil", "ski"):
        add_tag(page, name)
    add_thing(page, "lunettes", "soleil")
    page.get_by_role("button", name="Terminé").click()
    soleil = page.locator("section").filter(has=page.get_by_role("heading", name="soleil"))
    row = soleil.locator("li").filter(has_text="lunettes")
    row.get_by_role("checkbox", name="lunettes").click(delay=700)   # hold it
    chip = row.get_by_role("button", name="ski")
    expect(chip).to_have_attribute("aria-pressed", "false")         # not carried yet
    chip.click()
    assert page.get_by_text("lunettes").count() == 2                # now under both cards
    print("  PASS: chip tags a thing")

Tapping a filled chip is the other direction, and it takes off that one tag only — everything else the thing carries stays, because there was never a single place it lived to be pulled out of.

@testcase
def test_chip_untags_a_thing(page):
    """A filled chip tapped drops that tag alone; the thing's other tags survive."""
    clear_state(page)
    edit(page)
    for name in ("sac", "voyage"):
        add_tag(page, name)
    for tag in ("sac", "voyage"):
        add_thing(page, "chargeur", tag)
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    voyage = page.locator("section").filter(has=page.get_by_role("heading", name="voyage"))
    row = sac.locator("li").filter(has_text="chargeur")
    row.get_by_role("checkbox", name="chargeur").click(delay=700)
    row.get_by_role("button", name="sac").click()          # the filled chip
    assert sac.get_by_text("chargeur").count() == 0        # dropped here
    assert voyage.get_by_text("chargeur").is_visible()     # and nowhere else
    print("  PASS: chip untags a thing")

A trip — partir au ski, balade autour de la maison — is a tag like any other, with one difference: what it holds is not things you tagged one by one, but whole tags. It gathers them. Say a week-end takes ski and toilette and it carries everything under both, and a thing later tagged ski joins the trip without anyone touching the trip.

Planning is therefore one question — which tags does this trip take — and it deserves one screen and no more. The planner opens straight from the default mode, since planning is not curating; the trip’s name is one field and the tags are toggles under it; and each toggle is written as you make it, so there is nothing to confirm at the end. You leave by going back, and going back lands you in the trip, packing.

@testcase
def test_planner_gathers_tags(page):
    """Naming a trip and tapping two tags composes it, with nothing to confirm."""
    clear_state(page)
    edit(page)
    for name in ("ski", "toilette", "cuisine"):
        add_tag(page, name)
    page.get_by_role("button", name="Terminé").click()            # planning needs no edit mode
    page.get_by_role("button", name="Planifier une sortie").click()
    page.get_by_placeholder("Nom de la sortie").fill("week-end")
    planner = page.locator(".planner")
    planner.get_by_role("button", name="ski").click()
    planner.get_by_role("button", name="toilette").click()
    close_planner(page)                                           # closing lands in the trip
    we = page.locator("article.trip").filter(
        has=page.get_by_role("heading", name="week-end"))
    assert we.get_by_role("heading", name="ski").is_visible()
    assert we.get_by_role("heading", name="toilette").is_visible()
    assert we.get_by_role("heading", name="cuisine").count() == 0
    print("  PASS: planner gathers tags")

Most trips are not new: you go to the same places. So the planner’s name field completes against the trips already made, and picking one reopens it with its tags already lit — re-planning is toggling the difference, not describing the trip again.

@testcase
def test_planner_reopens_a_trip(page):
    """A trip picked from the planner's suggestions comes back with its tags lit."""
    clear_state(page)
    edit(page)
    for name in ("ski", "toilette"):
        add_tag(page, name)
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "week-end", ["ski"])
    page.get_by_role("button", name="Retour").click()             # leave the trip for the catalog
    page.get_by_role("button", name="Planifier une sortie").click()
    page.get_by_placeholder("Nom de la sortie").fill("week")
    planner = page.locator(".planner")
    planner.locator(".planner-trips").get_by_role("button", name="week-end").click()
    chips = planner.locator(".planner-tags")
    expect(chips.get_by_role("button", name="ski")).to_have_attribute("aria-pressed", "true")
    expect(chips.get_by_role("button", name="toilette")).to_have_attribute("aria-pressed", "false")
    print("  PASS: planner reopens a trip")

A trip also takes things no tag covers — the passport, the one pair of gloves. Under a container that needed its own step; here it is the ordinary gesture already learnt: the thing carries the trip’s tag. So the planner’s second half is the catalog’s own filter and a list of things, each a chip to tap on or off, and what you tap carries the trip’s name like any other tag.

@testcase
def test_planner_takes_single_things(page):
    """The planner can take an individual thing — it simply gains the trip's tag."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    add_thing(page, "gants", "ski")
    add_thing(page, "passeport")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Planifier une sortie").click()
    page.get_by_placeholder("Nom de la sortie").fill("week-end")
    planner = page.locator(".planner")
    planner.locator(".planner-things").get_by_role("button", name="passeport").click()
    close_planner(page)
    we = page.locator("article.trip")
    assert we.get_by_text("passeport").is_visible()
    assert we.get_by_role("heading", name="ski").count() == 0     # the ski tag was never gathered
    print("  PASS: planner takes single things")

A thing already coming with a gathered tag needs no chip of its own — offering it again would be a decision with no consequence, and the planner’s job is to have as few of those as possible. So gathering a tag drops its things out of the list below.

@testcase
def test_planner_drops_covered_things(page):
    """A thing covered by a gathered tag stops being offered on its own."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    add_thing(page, "gants", "ski")
    add_thing(page, "passeport")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Planifier une sortie").click()
    page.get_by_placeholder("Nom de la sortie").fill("week-end")
    planner = page.locator(".planner")
    things = planner.locator(".planner-things")
    assert things.get_by_role("button", name="gants").is_visible()      # ski not gathered -> offered
    assert things.get_by_role("button", name="passeport").is_visible()
    planner.locator(".planner-tags").get_by_role("button", name="ski").click()
    assert things.get_by_role("button", name="gants").count() == 0      # now covered -> dropped
    assert things.get_by_role("button", name="passeport").is_visible()
    print("  PASS: planner drops covered things")

A list you have curated for years grows past what any screen can show, and hunting down one thing by eye is the slowest thing you can be asked to do with a thumb. So the main screen carries a filter: type into it and only the things it matches remain, the rest falling away until the box is cleared.

@testcase
def test_filter_lists_matching_things(page):
    """The main-screen filter keeps only the things it matches; clearing it restores all."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("chargeur", "chaussettes", "livre"):
        add_thing(page, thing, "sac")
    page.get_by_label("Filtrer", exact=True).fill("chau")
    assert page.get_by_text("chaussettes").is_visible()
    assert page.get_by_text("chargeur").count() == 0
    assert page.get_by_text("livre").count() == 0
    page.get_by_label("Filtrer", exact=True).fill("")
    assert page.get_by_text("chargeur").is_visible()
    print("  PASS: filter lists matching things")

You rarely recall a label whole — a word or two of it comes back, in no particular order and often only part of each: uv shir for a t-shirt uv. So the filter matches by word, not by run: it keeps a thing when every word you typed turns up somewhere in its label, in any order, and it folds case and accents, so creme finds crème.

@testcase
def test_filter_matches_words_in_any_order(page):
    """Every typed word need only turn up somewhere in the label, in any order;
    case and accents are folded."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("t-shirt uv", "crème solaire", "casquette"):
        add_thing(page, thing, "sac")
    page.get_by_label("Filtrer", exact=True).fill("Uv shir")   # reversed order, a fragment, mixed case
    assert page.get_by_text("t-shirt uv").is_visible()
    assert page.get_by_text("casquette").count() == 0
    page.get_by_label("Filtrer", exact=True).fill("creme")     # accent-folded
    assert page.get_by_text("crème solaire").is_visible()
    print("  PASS: filter matches words in any order")

The planner’s own list of things grows just as long, and it is read under the same impatience, so it takes that same field.

@testcase
def test_planner_filters_things(page):
    """The planner's list of things can be narrowed by word."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for thing in ("gants", "bonnet", "crème"):
        add_thing(page, thing, "ski")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Planifier une sortie").click()
    page.get_by_placeholder("Nom de la sortie").fill("week-end")
    planner = page.locator(".planner")
    planner.get_by_label("Filtrer les choses").fill("gan")
    things = planner.locator(".planner-things")
    assert things.get_by_role("button", name="gants").is_visible()
    assert things.get_by_role("button", name="bonnet").count() == 0
    assert things.get_by_role("button", name="crème").count() == 0
    print("  PASS: planner filters things")

While planning you may notice a thing you have never listed at all. Typing it is the same field as everywhere else, and what comes out of it carries the trip’s tag already — you thought of it for this trip, so taking it along needs no second gesture.

@testcase
def test_planner_adds_new_thing(page):
    """A thing typed in the planner is created already carrying the trip's tag."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Planifier une sortie").click()
    page.get_by_placeholder("Nom de la sortie").fill("week-end")
    planner = page.locator(".planner")
    field = planner.get_by_placeholder("Ajouter une chose")
    field.fill("boussole")
    field.press("Enter")
    expect(planner.locator(".planner-things").get_by_role("button", name="boussole")
           ).to_have_attribute("aria-pressed", "true")   # taken along
    close_planner(page)
    we = page.locator("article.trip")
    assert we.get_by_text("boussole").is_visible()
    print("  PASS: planner adds new thing")

That row is the one place on this screen where a control could be pushed out of reach. It is a field and a button side by side inside a sheet, and phones still in use go down to 320 pixels across; a row wider than that hides its + behind a sideways scroll, and the thing you just typed cannot be added at all. So the + must land inside the screen at that width.

@testcase
def test_planner_add_row_fits(page):
    """On the narrowest phone, the planner's add button stays on screen."""
    ctx = page.context.browser.new_context(viewport={"width": 320, "height": 640})
    try:
        p = open_app(ctx, BASE_URL)
        p.get_by_role("button", name="Planifier une sortie").click()
        box = p.locator(".planner").get_by_role(
            "button", name="Ajouter la chose").bounding_box()
        assert box["x"] + box["width"] <= 321, box   # 320 wide, plus a pixel of rounding
    finally:
        ctx.close()
    print("  PASS: planner add row fits")

Everything so far has been what the thumb does. What it does it to is one Loro document, and that document turns out short, because none of it asked for more than this. There are things (items, id to label). There are names (tags, id to name, and whether it is a trip). A thing carries a name — tagged, keyed itemId:tagId. A trip takes in another name’s things — gathers, keyed tagId:tagId. The cards you curate under, the scratch list and the trips are not three kinds of thing in here: they are those same two maps, read from different ends.

Each membership is its own entry keyed by the pair, rather than a list hanging off either side. That is what lets two phones that tagged differently while apart both keep their work when they meet: neither wrote where the other had written. The trip flag is the one bit that is not a relation, and it buys exactly one thing — a chip above the catalog, a screen of its own to pack from.

Ticking and rearranging were promised too, and neither is a relation between two things, so neither belongs in those maps. checked marks what is packed, keyed by itemId alone, so a tick lands on the thing itself and not on one of the cards it shows up in. order records a manual sort position per membership, so a card’s things can be arranged by hand instead of left in the map’s own arbitrary key order.

import { render, html, svg } from 'uhtml';
import { get, set, del } from 'idb-keyval';

// created at boot, once Loro's WASM has loaded
let doc, items, tags, tagged, gathers, checked, order, undo;

Beside the document sits a second kind of state, and the line between them is worth drawing before either is written. Which mode this phone is in, what it has folded away, what a thumb has picked or revealed, how far down the page it has scrolled — none of that is a fact about the list. It is a fact about this reading of it, and the other phone holds its own answers. Syncing mine would be telling that phone something untrue. So it lives in plain module variables, never in the doc, and starts fresh every load. They are declared together, here and not each beside the screen that owns it, for one blunt reason: the first paint reads the lot, and a binding it reaches before this line has run is an error rather than a default.

let editMode = false, editing = null, editingText = '', folded = new Set(), selected = new Set();
let adding = null, addText = '', filter = '', picker = null;
let plan = null, focusId = null, toast = null, hideChecked = false, historyOpen = false, activeItem = null;
let viewingFrontier = null, viewingLabel = '', previewDoc = null;
let histRowHeights = null;
let pageShown;      // set by onArrival
let floatSet = null;   // set by onArrival, read by itemRows

Two more scraps are per-device for a different reason: they are the machinery of reaching the other phones rather than anything either phone believes. The socket and its retry delay are one; the storage keys the screen memory and the device’s identity are written under are the other, declared before boot so the very first paint can already save through them.

let ws, syncUrl, syncState = 'off', syncCode = '', retryDelay, syncUrlShown = false;

const UI_KEY = 'triggerlist.ui';
const PEER_KEY = 'triggerlist.peer';
let uiTimer;

That document has to survive the phone being closed, and the snapshot-to-IndexedDB the stack was chosen around is small enough to need no layer of its own: idb-keyval reads one key at start and writes it on change, debounced so a burst of edits is a single save. What the key is matters more than how it is written. It is scoped to the sync room (docKey), so each room — and the no-sync list, keyed local — caches its own document. A room joined for the first time is seeded from the local list, so work done offline carries into the room you share it to; but rooms never seed from one another, so an edit made in one room does not ride into another through the cache. An install predating this scoping keeps its data: the first room opened claims the old single-key cache once, then clears it.

function docKey(){ return 'triggerlist-doc:' + (syncUrl || 'local'); }

async function loadDoc(){
    let saved = await get(docKey());
    if(!saved && syncUrl)                         // a room joined for the first time
        saved = await get('triggerlist-doc:local');   // seeds from the offline list, not from other rooms
    if(!saved){                                   // an install from before per-room keying
        const legacy = await get('triggerlist-doc');
        if(legacy){ saved = legacy; await del('triggerlist-doc'); }
    }
    if(saved) doc.import(saved);
}

let saveTimer;
function persist(){
    clearTimeout(saveTimer);
    saveTimer = setTimeout(() => set(docKey(), doc.export({ mode: 'snapshot' })), 100);
}

A snapshot loaded from a phone that has been packing for a year was written under an older shape, where a thing was filed into a container rather than carrying a name: sections and outings, with a membership map apiece and a third for the things a trip took on its own. Read through the maps above, that list comes up empty — and losing a real packing list to a change of shape is not a cost worth paying for a cleaner model. So the old containers are taken over: both kinds become tags, the trips keeping their flag, and each of the three memberships lands in whichever of tagged and gathers now holds that fact.

Three things shape how that is done, and all three are about surviving a bad run. It claims each old root with getMap before reading it, because a root container nobody has asked for reads as nothing whether it is empty or holds a year of packing. It copies rather than replaces — a tag already there is left as it is — so a device that got only part of the way through, on an interrupted boot or under a version that got it wrong, is repaired the next time it starts instead of being stranded. And it empties the old maps last, key by key: that is what keeps it from running a second time, and doing it last is what makes a second run harmless when it never got there.

Then it saves, itself, immediately. The subscription that persists every change is wired only after boot, so a migration that did not write for itself would be run again from the same old snapshot on every single launch.

const LEGACY = ['sections', 'outings', 'itemSections', 'outingItems', 'outingSections'];

function adoptTags(){
    const old = {};
    for(const n of LEGACY) old[n] = doc.getMap(n).toJSON();   // getMap first: an unclaimed root reads as nothing
    if(!LEGACY.some(n => Object.keys(old[n]).length)) return;

    for(const [id, v] of Object.entries(old.sections))
        if(!tags.get(id)) tags.set(id, { name: v.name });
    for(const [id, v] of Object.entries(old.outings))
        if(!tags.get(id)?.trip) tags.set(id, { name: v.name, trip: 1 });
    for(const k of Object.keys(old.itemSections)) tagged.set(k, 1);
    for(const k of Object.keys(old.outingItems)) tagged.set(k, 1);   // itemId:outingId, already the right way round
    for(const k of Object.keys(old.outingSections)) gathers.set(k, 1);

    for(const n of LEGACY){ const m = doc.getMap(n); for(const k of Object.keys(old[n])) m.delete(k); }
    commit('liste reprise en étiquettes');
    persist();   // the subscribe that saves is not wired yet
}

What has to hold is not that the copy ran but that nothing went missing: every category a tag, every thing still under the one it was in, every trip still gathering what it gathered and still carrying what it took alone. What this is handed is a stored snapshot, and there is no gesture that produces one under a shape the app no longer writes — so the check plants one in the store the app reads at boot, which is precisely what an upgraded phone holds out to it.

@testcase
def test_old_shape_becomes_tags(page):
    """A list stored under the old shape comes back whole, as tags."""
    clear_state(page)
    page.evaluate("""async () => {
      const { LoroDoc } = await import('loro-crdt');
      const { set } = await import('idb-keyval');
      const d = new LoroDoc();
      d.getMap('items').set('gants', { label: 'gants' });
      d.getMap('items').set('passeport', { label: 'passeport' });
      d.getMap('sections').set('ski', { name: 'ski' });
      d.getMap('itemSections').set('gants:ski', 1);
      d.getMap('outings').set('week-end', { name: 'week-end' });
      d.getMap('outingSections').set('week-end:ski', 1);
      d.getMap('outingItems').set('passeport:week-end', 1);
      await set('triggerlist-doc:local', d.export({ mode: 'snapshot' }));
    }""")
    page.reload()
    page.wait_for_selector("#app > *")
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    assert ski.get_by_text("gants").is_visible()                 # the category is a tag, holding its things
    open_trip(page, "week-end")
    we = page.locator("article.trip")
    assert we.get_by_role("heading", name="ski").is_visible()    # the trip still gathers it
    assert we.get_by_text("passeport").is_visible()              # and still takes its own thing
    print("  PASS: old shape becomes tags")

And it must survive being run over its own work: a phone that crashed mid-copy, or one that ran a version of this that got it wrong, has to come out whole on the next launch rather than half-transcribed for good.

@testcase
def test_old_shape_adoption_repeats_safely(page):
    """Old maps left behind by a partial run are still taken over on a later boot."""
    clear_state(page)
    page.evaluate("""async () => {
      const { LoroDoc } = await import('loro-crdt');
      const { set } = await import('idb-keyval');
      const d = new LoroDoc();
      d.getMap('items').set('gants', { label: 'gants' });
      d.getMap('tags').set('ski', { name: 'ski' });      // the tag arrived
      d.getMap('sections').set('ski', { name: 'ski' });  // but the old maps were never emptied
      d.getMap('itemSections').set('gants:ski', 1);
      await set('triggerlist-doc:local', d.export({ mode: 'snapshot' }));
    }""")
    page.reload()
    page.wait_for_selector("#app > *")
    assert page.get_by_role("heading", name="ski").count() == 1   # one card, not two
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    assert ski.get_by_text("gants").is_visible()                  # and the membership came across
    print("  PASS: old shape adoption repeats safely")

Every screen opened over the catalog — a focused trip, and each of the sheets that will come — is also a step in the browser’s history. openScreen pushes a history entry and a closer; the device’s Back button (or the app’s own Back and Cancel controls, which merely call goBack) pops one entry, and popstate runs the closer on top. So Back stays in the app — dismissing the topmost sheet, then leaving a focused trip — instead of jumping out of it. Opening and closing both go through this one path, so history depth and the visible screens move together. The listener is registered here, at the module’s top level, so it is live before the first paint.

Once every screen is closed a Back press would leave the app, and leaving a packing list by a stray swipe is exactly the accident to guard against. So boot lays down a base guard entry, and when Back pops with no screen left to close, confirmExit asks first — re-arming the guard so the app stays put behind the question. Annuler dismisses it; Quitter walks back out past the app’s own entries, which lands somewhere to go whenever the app was reached through the browser (a fresh home-screen launch has nowhere behind it, and there the platform owns the exit).

const backStack = [];
let leaving = false;

function openScreen(close){
    backStack.push(close);
    history.pushState({ depth: backStack.length }, '');
}

function goBack(){ history.back(); }

function armGuard(){ history.pushState({ guard: true }, ''); }

function confirmExit(){
    if(document.querySelector('.exit-sheet')) return;   // already asking
    const back = document.createElement('div');
    back.className = 'sheet-back exit-sheet';
    back.onclick = backdropClose(() => back.remove());
    render(back, html`
      <div class="sheet" role="dialog" aria-label="Quitter">
        <p class="sheet-msg">Quitter l'application ?</p>
        <button onclick=${() => { back.remove(); leaving = true; history.go(-2); }}>Quitter</button>
        <button onclick=${() => back.remove()}>Annuler</button>
      </div>`);
    document.body.appendChild(back);
}

window.addEventListener('popstate', () => {
    const close = backStack.pop();
    if(close){ close(); return; }
    if(leaving) return;   // exit confirmed — let the browser walk out
    armGuard();           // no screen left: stay put and ask before leaving
    confirmExit();
});

A phone reloads the page on its own — swapped away and back, or the PWA refreshed — and landing at the top of the catalog loses your place mid-pack. So the app remembers, per device, which screen you were on and how far you had scrolled, and restores both on load: reload while packing a focused trip and you return to it, scrolled where you left off, with Back still leading to the catalog — a restored screen behaves just like one you navigated to. We treat screen as the focused-trip-or-catalog choice; the transient sheets and edit mode are not restored. The trigger is a reload; the same memory also brings you back when the PWA is reopened.

@testcase
def test_reload_restores_screen_and_scroll(page):
    """A reload returns to the focused trip, scrolled where you left it."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for i in range(20):
        add_thing(page, f"article {i}", "ski")
    plan_trip(page, "week-end", ["ski"])
    page.get_by_role("button", name="Terminé").click()
    page.mouse.wheel(0, 2000)
    page.wait_for_function("() => window.scrollY > 0")
    page.wait_for_timeout(300)   # let the ui snapshot debounce flush
    y = page.evaluate("window.scrollY")
    page.reload()
    page.wait_for_selector("#app > *")
    assert page.locator("article.trip").count() == 1                    # same screen
    page.wait_for_function(f"() => Math.abs(window.scrollY - {y}) < 50")   # same scroll
    print("  PASS: reload restores screen and scroll")

A screen you were put back on has to behave like one you walked to, or the restoring traps you: Back must reach the catalog, not the question about leaving the app.

@testcase
def test_reload_into_trip_can_go_back(page):
    """A trip restored on reload is navigable: Back returns to the catalog."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    plan_trip(page, "week-end", ["ski"])
    page.wait_for_timeout(300)   # let the ui snapshot flush
    page.reload()
    page.wait_for_selector("#app > *")
    assert page.locator("article.trip").count() == 1              # restored into the trip
    page.get_by_role("button", name="Retour").click()
    assert page.get_by_role("dialog", name="Quitter").count() == 0  # not the exit prompt
    assert page.locator("article.trip").count() == 0              # back at the catalog
    print("  PASS: reload into trip can go back")

The memory is a tiny per-device snapshot in localStorage — the focused trip’s id and the scroll offset — written after each repaint and on scroll, debounced so a burst is one write. restoreUi reads it once at boot and re-selects the trip if it still exists (a since-deleted one falls back to the catalog), before the first paint; the scroll is re-applied a frame later, once the restored screen has laid out. It shares the doc snapshot’s store, which is why it also survives the app being closed and reopened, not only a reload.

Beside it sits one more per-device scrap: the device’s own identity. Loro tags every change with the peer that made it, and left alone a page mints a fresh peer on each load — which would scatter one packer’s trail across many peers, so the history rail would paint a solo device as if it had branched. So devicePeerId mints a random 63-bit id once (crypto.getRandomValues, kept inside Loro’s peer-id range), stores it in localStorage, and =setPeerId=s it at boot before anything is imported: one device is one peer for good — one lane, one colour. Two devices draw their ids independently, so they stay effectively distinct without ever agreeing on anything. Changes already recorded keep the peer they were made under, so an old list may still show a past reload as its own lane; only new edits gather onto the stable identity.

function saveUi(){
    clearTimeout(uiTimer);
    uiTimer = setTimeout(() => localStorage.setItem(UI_KEY,
        JSON.stringify({ focusId, scrollY: window.scrollY })), 150);
}
function restoreUi(){
    let u = {};
    try { u = JSON.parse(localStorage.getItem(UI_KEY) || '{}'); } catch {}
    if(u.focusId && tags.get(u.focusId)) focusId = u.focusId;
    return u;
}
window.addEventListener('scroll', saveUi, { passive: true });

function devicePeerId(){
    let p = localStorage.getItem(PEER_KEY);
    if(!p){
        const r = new Uint32Array(2); crypto.getRandomValues(r);
        p = ((BigInt(r[0]) << 31n) | BigInt(r[1] >>> 1)).toString();   // 63 bits, inside Loro's peer-id range
        localStorage.setItem(PEER_KEY, p);
    }
    return BigInt(p);
}

Boot is where all of that is assembled, and the order it runs in is not arbitrary — every step is waiting on the one before it. The room has to be known before the cache can be read, since the cache is per room. The snapshot has to be in before the screen memory is honoured, since a trip that no longer exists is not a screen to return to. And both have to be settled before the first paint, or the reader watches the app arrive twice.

Three of its lines are choices rather than sequence. Loro is pulled in by dynamic import, so its WASM is off the path to that first paint. The undo manager’s merge window is set to zero, so each committed action is its own step to walk back rather than a rapid burst folding into one. And paint is the whole of the rendering: µhtml keeps no state, so painting is a read of the document into #app, plus the one flag the stylesheet needs to tint the page while editing.

What is left over goes last on purpose. The scroll offset waits a frame, because the restored screen has to lay itself out before there is anything to scroll. The service worker that makes the app installable is registered after the paint, since nothing on screen is waiting for it.

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

function paint(){ document.body.toggleAttribute('data-edit', editMode); document.body.toggleAttribute('data-viewing', !!viewingFrontier); render(app, view(buildModel())); saveUi(); if(historyOpen) requestAnimationFrame(relayoutHistory); }

const { LoroDoc, UndoManager } = await import('loro-crdt');
doc = new LoroDoc();
doc.setPeerId(devicePeerId());
doc.setRecordTimestamp(true);   // each change carries a time
items = doc.getMap('items');
tags = doc.getMap('tags');
tagged = doc.getMap('tagged');
gathers = doc.getMap('gathers');
checked = doc.getMap('checked');
order = doc.getMap('order');
undo = new UndoManager(doc, { mergeInterval: 0 });

resolveSyncUrl();
await loadDoc();
adoptTags();
const savedUi = restoreUi();
doc.subscribe(() => { paint(); persist(); });
paint();
document.getElementById('loading')?.remove();
if(savedUi.scrollY) requestAnimationFrame(() => window.scrollTo(0, savedUi.scrollY));
startSync();
armGuard();
if(focusId) openScreen(() => { focusId = null; paint(); });   // the restored screen needs its entry too
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js');

Packing a trip is the point of the whole list, and for that you want just that trip in front of you. Tapping its chip focuses it — the rest of the list falls away, leaving only its cards and things to tick — and a back control returns to the catalog.

@testcase
def test_focus_trip(page):
    """Focusing a trip shows only it; back returns to the catalog."""
    clear_state(page)
    edit(page)
    for thing, tag in (("gants", "ski"), ("crème", "plage")):
        add_tag(page, tag)
        add_thing(page, thing, tag)
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "week-end", ["ski"])
    assert page.get_by_text("gants").is_visible()
    assert page.get_by_text("crème").count() == 0
    page.get_by_role("button", name="Retour").click()
    assert page.get_by_text("crème").is_visible()
    print("  PASS: focus trip")

Curation is not just for the catalog: the Modifier/–/Terminé toggle rides the focused view too, so a trip can be edited without stepping back out to the list.

@testcase
def test_toggle_edit_in_trip(page):
    """The modify toggle is available inside a focused trip."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    plan_trip(page, "week-end", ["ski"])
    assert page.locator("article.trip").count() == 1
    page.get_by_role("button", name="Terminé").click()                      # leave edit, here
    assert page.get_by_role("button", name="Modifier", exact=True).is_visible()
    assert page.locator("article.trip").count() == 1                      # still focused
    page.get_by_role("button", name="Modifier", exact=True).click()         # back into edit, here
    assert page.get_by_role("button", name="Terminé").is_visible()
    print("  PASS: toggle edit in trip")

A trip is rarely right the first time, so its focused view carries the planner too: one control reopens it on this trip, the tags it gathers already lit, to take one more along or drop one.

@testcase
def test_modify_trip(page):
    """A focused trip is re-planned in place — one tag dropped, another gathered."""
    clear_state(page)
    edit(page)
    for name in ("ski", "toilette"):
        add_tag(page, name)
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "week-end", ["ski"])
    page.get_by_role("button", name="Modifier la sortie").click()
    chips = page.locator(".planner .planner-tags")
    chips.get_by_role("button", name="ski").click()        # lit — tapped off
    chips.get_by_role("button", name="toilette").click()   # dark — tapped on
    close_planner(page)
    we = page.locator("article.trip")
    assert we.get_by_role("heading", name="toilette").is_visible()
    assert we.get_by_role("heading", name="ski").count() == 0
    print("  PASS: modify trip")

On a phone the natural way out of a screen is the device’s Back button, so it must dismiss whatever is on top rather than leave the app. Back closes the planner, dismisses the batch tag sheet, and steps out of a focused trip — and when screens are stacked it peels them off one at a time, closing the top first and only then leaving the trip.

@testcase
def test_back_closes_planner(page):
    """The device Back button closes the planner."""
    clear_state(page)
    page.get_by_role("button", name="Planifier une sortie").click()
    assert page.locator(".planner").count() == 1
    page.go_back()
    page.wait_for_function("() => !document.querySelector('.planner')")
    print("  PASS: back closes planner")

The batch tag sheet answers the same press, and answering it must cost nothing: a sheet raised by mistake goes away leaving the list exactly as it was.

@testcase
def test_back_closes_picker(page):
    """Back dismisses the batch tag sheet without tagging anything."""
    clear_state(page)
    edit(page)
    add_tag(page, "soleil")
    add_tag(page, "ski")
    add_thing(page, "lunettes", "soleil")
    soleil = page.locator("section").filter(has=page.get_by_role("heading", name="soleil"))
    soleil.locator("li").filter(has_text="lunettes").get_by_role(
        "checkbox", name="Sélectionner").check()
    page.get_by_role("button", name="Étiqueter").click()
    assert page.locator(".sheet.picker").count() == 1
    page.go_back()
    page.wait_for_function("() => !document.querySelector('.sheet.picker')")
    assert page.get_by_text("lunettes").count() == 1   # nothing tagged
    print("  PASS: back closes picker")

A focused trip is a screen too, not a place you are stuck in, so with no sheet on top the same press hands you back the catalog.

@testcase
def test_back_leaves_focus(page):
    """Back steps out of a focused trip and returns to the catalog."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    plan_trip(page, "week-end", ["ski"])
    assert page.locator("article.trip").count() == 1
    page.go_back()
    page.wait_for_function("() => document.querySelector('.chip')")
    assert page.locator("article.trip").count() == 0
    print("  PASS: back leaves focus")

Stacked, those two must not collapse into one press. Re-planning from inside a trip puts a sheet over a screen, and a Back that took both away would drop you out of the trip you were about to pack.

@testcase
def test_back_unwinds_stacked_screens(page):
    """With the planner open over a focused trip, Back peels off one screen at a time."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "week-end", ["ski"])
    page.get_by_role("button", name="Modifier la sortie").click()
    assert page.locator(".planner").count() == 1
    page.go_back()                                    # closes the planner only
    page.wait_for_function("() => !document.querySelector('.planner')")
    assert page.locator("article.trip").count() == 1   # still in the focused trip
    page.go_back()                                    # then leaves the trip
    page.wait_for_function("() => document.querySelector('.chip')")
    assert page.locator("article.trip").count() == 0
    print("  PASS: back unwinds stacked screens")

With every screen closed, one more Back would leave the app; from the base list it asks first instead. The window marker survives, so the prompt appeared without the document reloading, and Annuler keeps you in.

@testcase
def test_back_asks_before_leaving(page):
    """From the base list, Back asks before leaving the app instead of exiting."""
    clear_state(page)
    page.evaluate("window.__inapp = true")
    page.go_back()
    page.wait_for_function("() => document.querySelector('.exit-sheet')")
    assert page.evaluate("window.__inapp") is True          # same document, not gone
    page.get_by_role("dialog", name="Quitter").get_by_role(
        "button", name="Annuler").click()
    page.wait_for_function("() => !document.querySelector('.exit-sheet')")
    assert page.evaluate("window.__inapp") is True          # Annuler keeps us in
    assert page.get_by_role("button", name="Modifier", exact=True).count() == 1
    print("  PASS: back asks before leaving")

And confirming the prompt does walk back out — where the app was reached through the browser there is somewhere to go, so Quitter navigates away from the document.

@testcase
def test_exit_confirmation_leaves(page):
    """Confirming the exit prompt navigates back out of the app."""
    clear_state(page)
    page.go_back()
    page.wait_for_function("() => document.querySelector('.exit-sheet')")
    with page.expect_navigation():
        page.get_by_role("dialog", name="Quitter").get_by_role(
            "button", name="Quitter").click()
    print("  PASS: exit confirmation leaves")

Back is one way out of a sheet; the other, just as natural under a thumb, is to tap the dimmed area around it. Every sheet floats on that dimmed backdrop, so a tap landing on the backdrop rather than on the sheet dismisses it, exactly as the sheet’s own Annuler would. The one thing to get right is the tap that lands inside the sheet: it bubbles up to the backdrop too, and must be left alone, so the handler acts only when the click’s own target is the backdrop itself.

function backdropClose(close){ return e => { if(e.target === e.currentTarget) close(); }; }

The planner is such a sheet: tapping outside it leaves planning, the same as the device’s Back.

@testcase
def test_backdrop_closes_planner(page):
    """Tapping the dimmed area outside the planner dismisses it, like Back."""
    clear_state(page)
    page.get_by_role("button", name="Planifier une sortie").click()
    assert page.get_by_role("dialog", name="Planifier une sortie").is_visible()
    page.locator(".sheet-back").click(position={"x": 8, "y": 8})
    expect(page.get_by_role("dialog", name="Planifier une sortie")).to_have_count(0)
    print("  PASS: backdrop closes planner")

The batch tag sheet is the same kind of sheet, so it answers the same tap: raise it, tap outside, and it goes without tagging anything.

@testcase
def test_backdrop_closes_picker(page):
    """Tapping outside the batch tag sheet dismisses it, tagging nothing."""
    clear_state(page)
    edit(page)
    add_tag(page, "soleil")
    add_tag(page, "ski")
    add_thing(page, "lunettes", "soleil")
    soleil = page.locator("section").filter(has=page.get_by_role("heading", name="soleil"))
    soleil.locator("li").filter(has_text="lunettes").get_by_role(
        "checkbox", name="Sélectionner").check()
    page.get_by_role("button", name="Étiqueter").click()
    assert page.get_by_role("dialog", name="Étiqueter").is_visible()
    page.locator(".sheet-back").click(position={"x": 8, "y": 8})
    expect(page.get_by_role("dialog", name="Étiqueter")).to_have_count(0)
    assert page.get_by_text("lunettes").count() == 1   # nothing tagged
    print("  PASS: backdrop closes picker")

The exit prompt shares the gesture: tapping outside it does the safe thing its Annuler does — dismiss the question and keep you in the app.

@testcase
def test_backdrop_closes_exit_prompt(page):
    """Tapping outside the exit prompt cancels it, staying in the app."""
    clear_state(page)
    page.evaluate("window.__inapp = true")
    page.go_back()
    page.wait_for_function("() => document.querySelector('.exit-sheet')")
    page.locator(".sheet-back").click(position={"x": 8, "y": 8})
    expect(page.get_by_role("dialog", name="Quitter")).to_have_count(0)
    assert page.evaluate("window.__inapp") is True          # same document, not gone
    print("  PASS: backdrop closes exit prompt")

It is the one sheet built by hand rather than painted from the doc, so its backdrop tap is wired where it is created — the fragment held back from confirmExit above.

back.onclick = backdropClose(() => back.remove());

A long list reads more easily folded up. Tapping a card’s title hides its things and shows them again, so the cards you are not packing right now can be collapsed out of the way. Folding is a view preference, the same in both modes and not part of the shared document.

@testcase
def test_fold_card(page):
    """Tapping a card's title hides its things; tapping again shows them."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    add_thing(page, "gants", "ski")
    assert page.get_by_text("gants").is_visible()
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    ski.get_by_role("button", name="ski").click()
    assert page.get_by_text("gants").count() == 0
    ski.get_by_role("button", name="ski").click()
    assert page.get_by_text("gants").is_visible()
    print("  PASS: fold card")

Folding cards one by one is slow on a big list, so the toolbar has a single control that folds every card at once and, tapped again, opens them all up.

@testcase
def test_fold_all(page):
    """One toolbar control folds every card; the next tap unfolds them all."""
    clear_state(page)
    edit(page)
    for name in ("plage", "ski"):
        add_tag(page, name)
    for thing, sec in (("crème", "plage"), ("gants", "ski")):
        add_thing(page, thing, sec)
    assert page.get_by_text("crème").is_visible()
    page.get_by_role("button", name="Tout plier").click()
    assert page.get_by_text("crème").count() == 0
    assert page.get_by_text("gants").count() == 0
    page.get_by_role("button", name="Tout déplier").click()
    assert page.get_by_text("crème").is_visible()
    assert page.get_by_text("gants").is_visible()
    print("  PASS: fold all")

The focused trip carries the same fold-all, since a trip’s own cards are exactly what you want to collapse while packing it.

@testcase
def test_fold_all_in_trip(page):
    """The focused trip has a fold-all that collapses its cards and reopens them."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    add_thing(page, "gants", "ski")
    plan_trip(page, "week-end", ["ski"])
    assert page.get_by_text("gants").is_visible()
    page.get_by_role("button", name="Tout plier").click()
    assert page.get_by_text("gants").count() == 0
    page.get_by_role("button", name="Tout déplier").click()
    assert page.get_by_text("gants").is_visible()
    print("  PASS: fold all in trip")

A card you have finished needs no fold at all. Arriving at a page — opening a trip, or coming back to the catalog — the cards already ticked off collapse themselves, so what greets you is only what is still to pack; the done ones tuck away until you go looking. It waits for a real change of page, so ticking the last thing in a card right in front of you leaves it open — it folds next time you arrive, not under your hands. And it is a packing convenience, not a curating one: while you are in edit mode arranging the list, a done card stays put rather than folding away as you work.

@testcase
def test_finished_cards_fold_on_arrival(page):
    """Arriving at a page folds the cards already fully ticked; the unfinished ones stay open."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    add_tag(page, "plage")
    add_thing(page, "ballon", "plage")
    plan_trip(page, "week-end", ["sac", "plage"])
    page.get_by_role("button", name="Terminé").click()      # into the packing view
    page.get_by_role("checkbox", name="tente").click()       # « sac » is now done; « plage » is not
    assert page.get_by_text("tente").is_visible()            # still open — no page change yet
    page.get_by_role("button", name="Retour").click()        # change page: back to the catalog
    assert page.get_by_text("tente").count() == 0            # « sac » folded itself away on arrival
    assert page.get_by_text("ballon").is_visible()           # « plage » is unfinished, so it stays open
    print("  PASS: finished cards fold on arrival")

Curating wants the opposite. Arranging a list means seeing all of it, so arriving in edit mode leaves even a finished card open.

@testcase
def test_finished_cards_stay_open_while_editing(page):
    """Arriving at a page while editing leaves a done card open — folding serves packing, not curating."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    plan_trip(page, "week-end", ["sac"])                  # focused in the trip, edit mode
    page.get_by_role("button", name="Terminé").click()      # view mode, still in the trip
    page.get_by_role("checkbox", name="tente").click()       # « sac » done, but no page change — still open
    page.get_by_role("button", name="Modifier", exact=True).click()   # back into edit mode, no page change
    page.get_by_role("button", name="Retour").click()        # change page to the catalog, while editing
    assert page.get_by_text("tente").is_visible()            # « sac » stayed open: editing, not packing
    print("  PASS: finished cards stay open while editing")

Nearing the end, the few things still to pack are scattered among cards mostly done, and hunting them out is the tedious part. So when only a handful are left — fewer than fifteen — arriving at a page floats the still-unticked things to the top of their card, so the stragglers are what meets your eye rather than rows already packed. Like the folding, this settles on arrival and holds through the session: a thing you tick stays where it floated rather than dropping away under your hand, and while plenty is still to pack the order is left as it is.

@testcase
def test_remaining_things_float_up_on_arrival(page):
    """With only a few left to pack, arriving at a page floats the unticked things above the packed ones."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for t in ("alpha", "bravo", "charlie"):
        add_thing(page, t, "sac")
    page.get_by_role("button", name="Terminé").click()       # packing view
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    shown = [sac.locator("li span.label").nth(i).inner_text() for i in range(3)]
    packed, straggler = shown[:2], shown[2]                   # pack all but the last one shown
    for t in packed:
        page.get_by_role("checkbox", name=t).click()
    assert sac.locator("li span.label").first.inner_text() == shown[0]   # no page change yet — order untouched
    plan_trip(page, "week-end", ["sac"])                      # change page: arrive in the trip
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    assert sac.locator("li span.label").first.inner_text() == straggler  # it floated to the top
    page.get_by_role("checkbox", name=straggler).click()      # tick it right there
    assert sac.locator("li span.label").first.inner_text() == straggler  # it holds its place, not dropping under your hand
    print("  PASS: remaining things float up on arrival")

Early on it would be a nuisance rather than a help: with most of the list still to pack, reshuffling it every time you arrive costs you the arrangement you chose and buys nothing, so the order is left alone until the stretch is genuinely final.

@testcase
def test_many_remaining_do_not_float(page):
    """While plenty is still to pack, arriving leaves the order alone — floating is for the final stretch."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for i in range(16):
        add_thing(page, f"chose {i:02d}", "sac")
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    first = sac.locator("li span.label").first.inner_text()
    page.get_by_role("checkbox", name=first).click()          # pack one — fifteen still to go
    plan_trip(page, "week-end", ["sac"])                      # arrive in the trip
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    assert sac.locator("li span.label").first.inner_text() == first   # at the threshold: the packed one keeps its place
    print("  PASS: many remaining do not float")

And it holds off while you curate, for the reason the folding does: an order you are in the middle of arranging by hand must not be rearranged for you.

@testcase
def test_remaining_do_not_float_while_editing(page):
    """Arriving while editing leaves the order alone — floating, like folding, is for packing, not curating."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for t in ("alpha", "bravo", "charlie"):
        add_thing(page, t, "sac")
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    shown = [sac.locator("li span.label").nth(i).inner_text() for i in range(3)]
    for t in shown[:2]:
        page.get_by_role("checkbox", name=t).click()          # only the last one shown is left
    plan_trip(page, "week-end", ["sac"])                      # arrive in the trip
    edit(page)
    page.get_by_role("button", name="Retour").click()         # arrive at the catalog while editing
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    assert sac.locator("li span.label").first.inner_text() == shown[0]   # order untouched: curating, not packing
    print("  PASS: remaining do not float while editing")

A trip that gathers several cards is itself a long list to pack from, so the focused trip carries the same filter as the catalog — scoped here to the trip’s own things.

@testcase
def test_filter_in_focused_trip(page):
    """The focused trip's filter narrows its things by word."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for thing in ("gants", "bonnet", "crème"):
        add_thing(page, thing, "ski")
    plan_trip(page, "week-end", ["ski"])
    page.get_by_label("Filtrer", exact=True).fill("gan")
    assert page.get_by_text("gants").is_visible()
    assert page.get_by_text("bonnet").count() == 0
    assert page.get_by_text("crème").count() == 0
    page.get_by_label("Filtrer", exact=True).fill("")
    assert page.get_by_text("bonnet").is_visible()
    print("  PASS: filter in focused trip")

A card shows how far along it is — how many of its things are ticked over how many there are — so within a trip you can see at a glance which parts of the trip are already packed. The tally rides in the card heading just to the right of the name and follows the ticks live: the count is about that card’s contents, so it stays with the name, while the heading’s right edge is kept for the controls that mutate the card.

@testcase
def test_card_count_in_trip(page):
    """A card shows how many things are ticked over its total, in the focused trip."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for thing in ("gants", "bonnet"):
        add_thing(page, thing, "ski")
    plan_trip(page, "week-end", ["ski"])
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    expect(ski.get_by_text("0/2")).to_be_visible()
    ski.get_by_role("checkbox", name="gants").click()
    expect(ski.get_by_text("1/2")).to_be_visible()
    print("  PASS: card count in trip")

@testcase
def test_count_is_right_of_the_title(page):
    """The tally hugs the name — right of it, not out in the mutation cluster."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for thing in ("gants", "bonnet"):
        add_thing(page, thing, "ski")
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    heading = ski.get_by_role("heading", name="ski")
    title = ski.get_by_role("button", name="ski").bounding_box()
    count = ski.get_by_text("0/2").bounding_box()
    pencil = heading.get_by_role("button", name="Renommer").bounding_box()
    assert count["x"] > title["x"], (title, count)                          # right of the name
    assert count["x"] - title["x"] < pencil["x"] - count["x"], (title, count, pencil)  # beside the name, not the mutation cluster
    print("  PASS: count is right of the title")

Chip by chip is right for one thing and tedious for eight, and a whole shelf turning out to belong together is the ordinary case. So in edit mode each thing gets a selection box: tick several, press Étiqueter once and name the tag, and they all take it together — one change, one undo step. Détacher is the same gesture in reverse, taking the named tag off every one of them.

@testcase
def test_multi_select_tag(page):
    """Several selected things take one tag together, and lose it together."""
    clear_state(page)
    edit(page)
    for name in ("soleil", "ski"):
        add_tag(page, name)
    for thing in ("lunettes", "casquette"):
        add_thing(page, thing, "soleil")
    soleil = page.locator("section").filter(has=page.get_by_role("heading", name="soleil"))
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    def pick_both():
        for thing in ("lunettes", "casquette"):
            soleil.locator("li").filter(has_text=thing).get_by_role(
                "checkbox", name="Sélectionner").check()
    pick_both()
    page.get_by_role("button", name="Étiqueter").click()
    page.get_by_role("dialog", name="Étiqueter").get_by_role("button", name="ski").click()
    assert ski.get_by_text("lunettes").is_visible()
    assert ski.get_by_text("casquette").is_visible()
    pick_both()
    page.get_by_role("button", name="Détacher").click()
    page.get_by_role("dialog", name="Détacher").get_by_role("button", name="ski").click()
    assert ski.get_by_text("lunettes").count() == 0
    assert ski.get_by_text("casquette").count() == 0
    assert soleil.get_by_text("lunettes").is_visible()   # picked under soleil, still there
    print("  PASS: multi select tag")

The order things sit in a card is personal: you pack in the sequence that suits you — heavy things first, or by the pocket they go in — and the order you happened to add them in rarely matches that. So in edit mode each thing grows a drag handle (); dragging a thing by that handle slides it up or down within its card into the order you want. The handle is a place of its own, so tapping the thing still ticks it — reordering never fights packing.

@testcase
def test_reorder_thing_in_card(page):
    """Dragging a thing by its grip reorders it within its card."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for thing in ("gants", "bonnet", "casque"):
        add_thing(page, thing, "ski")
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    before = [ski.locator("li span.label").nth(i).inner_text() for i in range(3)]
    moved = before[2]
    drag_reorder(page, '[data-reorder="tag:ski"]', 2, 0)   # last thing to the top
    after = [ski.locator("li span.label").nth(i).inner_text() for i in range(3)]
    assert after == [moved] + [x for x in before if x != moved], (before, after)
    print("  PASS: reorder thing in card")

Cards reorder the same way. In edit mode each card heading carries the same grip, and dragging it slides the whole card up or down the catalog into the order you pack them in.

@testcase
def test_reorder_card(page):
    """Dragging a card by its grip reorders it in the catalog."""
    clear_state(page)
    edit(page)
    for name in ("ski", "plage", "cuisine"):
        add_tag(page, name)
    titles = page.locator("section .fold-title")
    names = lambda: [titles.nth(i).inner_text().lstrip("▾▸ ").strip() for i in range(3)]
    before = names()
    moved = before[2]
    drag_reorder(page, '[data-reorder="tags"]', 2, 0)   # last card to the top
    after = names()
    assert after == [moved] + [x for x in before if x != moved], (before, after)
    print("  PASS: reorder card")

We do not hand-roll the drag. A shared, framework-agnostic reorder engine handles the whole gesture — pointer tracking, the floating clone, the placeholder, and auto-scroll when the finger nears a screen edge — and stays out of our data: as the finger crosses into a new slot it fires a reorder:move event carrying from and to, and we do the rest. It is pointer-based on purpose, so a test drag takes the same path as a real finger. We pull in its CSS and its JS:

nil

nil

On reorder:move we update our own store — nothing else. In practice, because µhtml repaints a list by position rather than by keyed identity, repainting mid-drag would pull the dragged node out from under the engine; so we do not write to the doc on every crossing. We remember the drag’s start slot and its latest target, and only on pointerup do we splice the moved element into its new slot and renumber the range’s order, in a single commit — one undo step.

let dragOrder = null;

document.addEventListener('reorder:move', (e) => {
    const key = e.target.closest('.reorder-list')?.dataset.reorder;
    if(!key) return;
    if(!dragOrder || dragOrder.key !== key) dragOrder = { key, from: e.detail.from, to: e.detail.to };
    else dragOrder.to = e.detail.to;
});
document.addEventListener('pointerup', () => {
    if(!dragOrder) return;
    const { key, from, to } = dragOrder;
    dragOrder = null;
    if(from === to) return;
    if(key === 'tags'){
        const kf = (id) => 't:' + id;
        const ids = Object.keys(tags.toJSON()).filter(id => !tags.get(id).trip);
        applyReorder(sortByOrder(ids, kf), kf, from, to);
    } else if(key.startsWith('tag:')){
        const tag = key.slice(4), kf = (id) => 'i:' + id + ':' + tag;
        applyReorder(sortByOrder(taggedWith(tag), kf), kf, from, to);
    }
});

The drag key names the list — tags, or tag:<id> for the things under one tag — and each maps to a keyspace in order (t:<id>, i:<itemId>:<tagId>). Two helpers do the arithmetic for either: sortByOrder reads a card’s ids into their current order (the same order ?? insertion-index collect uses, so the drag indices line up with what is on screen), and applyReorder splices the moved id into its new slot and rewrites the whole range’s positions.

function taggedWith(tag){
    return Object.keys(tagged.toJSON()).filter(k => after(k) === tag && items.get(before(k))).map(before);
}
function sortByOrder(ids, keyOf){
    return ids.map((id, i) => ({ id, pos: order.get(keyOf(id)) ?? i })).sort((a, b) => a.pos - b.pos).map(x => x.id);
}
function applyReorder(ids, keyOf, from, to){
    if(from < 0 || from >= ids.length) return;
    const [moved] = ids.splice(from, 1);
    ids.splice(to, 0, moved);
    ids.forEach((id, i) => order.set(keyOf(id), i));
    commit('réordonné');
}

The shared styling is dark-themed — it reads --accent and --muted and paints rows on a translucent white that vanishes on a light page. So we hand it those two variables and keep the rows’ own light look. Two more rules pin the edit-mode cards: the wrapper is capped at the catalog’s own 480 and centred, and each card fills it, so a card’s title sits on the same left edge as the rest of the list:

:root{--accent:#1b1d2e;--muted:#8a8ea5}
.items.reorder-list{padding:0 16px}
.items li.reorder-item{background:#f2f3f7;padding:12px 14px;border-radius:8px;gap:6px;margin-bottom:0}
[data-reorder="tags"]{max-width:480px;margin:0 auto}
section.reorder-item{display:block;max-width:none;margin:0;background:transparent;padding:0;border-radius:0;user-select:auto}

@testcase
def test_card_title_left_in_edit_mode(page):
    """In edit mode a card title stays flush-left, level with the catalog's own left edge."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    ski = page.get_by_role("heading", name="ski").bounding_box()
    sans = page.get_by_role("heading", name="Sans étiquette").bounding_box()
    assert abs(ski["x"] - sans["x"]) < 4, (ski, sans)
    print("  PASS: card title left in edit mode")

Dragging is one way to order a card; the other is to let the alphabet do it. Each card heading carries an A→Z button in edit mode that sorts that card’s things by name — the quick way when the hand-arranged order no longer matters, or to tidy a list that grew in whatever order things were remembered.

@testcase
def test_sort_card_alpha(page):
    """The A→Z button sorts a card's things alphabetically."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("tente", "boussole", "corde"):
        add_thing(page, thing, "sac")
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    sac.get_by_role("button", name="Trier de A à Z").click()
    names = [sac.locator("li span.label").nth(i).inner_text() for i in range(3)]
    assert names == ["boussole", "corde", "tente"], names
    print("  PASS: sort card alpha")

sortTagAlpha reuses the same order map: it reads the card’s things, sorts them by label, and writes their new positions in one commit — so an alphabetical sort is itself just another arrangement, undone by the same Défaire.

function sortTagAlpha(tag){
    const ids = taggedWith(tag)
        .sort((a, b) => (items.get(a)?.label || '').localeCompare(items.get(b)?.label || '', 'fr'));
    ids.forEach((id, i) => order.set('i:' + id + ':' + tag, i));
    commit('« ' + (tags.get(tag)?.name || tag) + ' » trié A→Z');
}

You tick a thing off as you pack it. Because a thing is one thing however many tags it carries, ticking it anywhere ticks it everywhere: check it under soleil and it is checked under ski too, since there is only one of it to pack.

@testcase
def test_check_is_per_item(page):
    """A thing carrying two tags, checked under one, shows checked under both."""
    clear_state(page)
    edit(page)
    for name in ("soleil", "ski"):
        add_tag(page, name)
    for tag in ("soleil", "ski"):
        add_thing(page, "crème solaire", tag)
    boxes = page.get_by_role("checkbox", name="crème solaire")
    expect(boxes).to_have_count(2)
    boxes.first.click()
    expect(boxes.first).to_be_checked()
    expect(boxes.last).to_be_checked()
    boxes.first.click()
    expect(boxes.first).not_to_be_checked()
    expect(boxes.last).not_to_be_checked()
    print("  PASS: check is per item")

A tick is easy to make by accident — a stray tap while scrolling — and just as easy to miss. So checking or unchecking a thing flashes it: flashItem glows every row the thing shows in, briefly, to pull the eye to what just changed.

function flashItem(id){
    requestAnimationFrame(() => document.querySelectorAll('[data-item="' + CSS.escape(id) + '"]')
        .forEach(el => el.animate(
            [{ boxShadow: '0 0 0 3px #f6c453' }, { boxShadow: '0 0 0 0 rgba(246,196,83,0)' }],
            { duration: 500, easing: 'ease-out' })));
}

As a list gets ticked off, the packed things only take up room between the ones still to find. So the toolbar carries a toggle that hides the ticked things, leaving just what is left to pack; toggled again, they all come back. The card tallies keep their true totals — hiding a thing does not pretend it is gone.

@testcase
def test_hide_checked_items(page):
    """A toolbar toggle hides ticked things (tally stays true) and reveals them again."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("tente", "corde"):
        add_thing(page, thing, "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("checkbox", name="tente").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    page.get_by_role("button", name="Masquer les choses cochées").click()
    assert page.get_by_text("tente").count() == 0            # the ticked thing is hidden
    assert page.get_by_text("corde").is_visible()            # the unticked one stays
    assert sac.get_by_text("1/2").is_visible()               # the tally still counts it
    page.get_by_role("button", name="Afficher les choses cochées").click()
    assert page.get_by_text("tente").is_visible()            # toggled back
    print("  PASS: hide checked items")

Whether the packed rows are hidden is a preference of this reading, not a fact about the list, so it lives with the rest of the per-device state and the toggle is the whole of it. It does not cross into edit mode: a drag needs every row present to drop between, so curating always sees the full list.

function toggleHideChecked(){ hideChecked = !hideChecked; paint(); }

Hiding has a sharp edge: with it on, a ticked row leaves the list at once, and a row that simply blinks out is a tick the eye can miss — the very stray scroll-tap the flash was meant to catch. So a tick made while hidden is not a disappearance but a departure you watch: collapseThenCheck flashes every row the thing shows in amber and shrinks it to nothing, and only once the row has finished collapsing does the tick land and the row go. The motion carries the message on its own, so there is no toast to dismiss; with the ticked things left visible the row stays put and the ordinary glow does the same work. Should the rows be gone already — a concurrent repaint from the other phone — there is nothing to animate, so the tick just lands.

function collapseThenCheck(itemId){
    const rows = [...document.querySelectorAll('[data-item="' + CSS.escape(itemId) + '"]')];
    if(!rows.length){ applyCheck(itemId, true); return; }
    let pending = rows.length;
    const done = () => { if(--pending === 0) applyCheck(itemId, true); };
    rows.forEach(el => {
        const h = el.offsetHeight;
        el.style.overflow = 'hidden';
        const anim = el.animate(
            [{ backgroundColor: '#f6c453', maxHeight: h + 'px', opacity: 1 },
             { backgroundColor: '#f6c453', maxHeight: h + 'px', opacity: 1, offset: .3 },
             { backgroundColor: 'transparent', maxHeight: '0px', opacity: 0, transform: 'scale(.9)' }],
            { duration: 380, easing: 'ease-in' });
        anim.onfinish = anim.oncancel = done;   // a repaint that cancels the animation still lands the tick
    });
}

Let us watch a tick land while hidden: the row must go — after its collapse, not before — leave no toast behind, and the thing must come back ticked when the hidden rows are shown again, proof the check truly landed rather than being merely hidden.

@testcase
def test_tick_while_hidden_collapses_then_checks(page):
    """Ticking while hidden shrinks the row away with no toast; showing again reveals it ticked."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("tente", "corde"):
        add_thing(page, thing, "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Masquer les choses cochées").click()
    page.get_by_role("checkbox", name="tente").click()             # ticked -> collapses away
    expect(page.get_by_role("checkbox", name="tente")).to_have_count(0)   # gone once collapsed
    assert page.get_by_role("status").count() == 0                 # no toast
    assert page.get_by_role("checkbox", name="corde").is_visible()  # the unticked one stays
    page.get_by_role("button", name="Afficher les choses cochées").click()
    expect(page.get_by_role("checkbox", name="tente")).to_be_checked()   # the tick really landed
    print("  PASS: tick while hidden collapses then checks")

Ticking a card’s last thing means it is fully packed — worth seeing at a glance so you can move on to the next. So when every thing in a card is checked, the whole card turns green. The colour stands whether or not the checked things are hidden — a card packed down to just its header still reads green.

@testcase
def test_card_green_when_all_checked(page):
    """A card turns green once all its things are checked."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("tente", "corde"):
        add_thing(page, thing, "sac")
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    page.get_by_role("checkbox", name="tente").click()
    page.get_by_role("checkbox", name="corde").click()
    rgb = [int(x) for x in re.findall(r"\d+", sac.evaluate("el => getComputedStyle(el).backgroundColor"))[:3]]
    assert rgb[1] > rgb[0] and rgb[1] > rgb[2], rgb   # greenish
    page.get_by_role("button", name="Masquer les choses cochées").click()   # green must persist with checked hidden
    rgb2 = [int(x) for x in re.findall(r"\d+", sac.evaluate("el => getComputedStyle(el).backgroundColor"))[:3]]
    assert rgb2[1] > rgb2[0] and rgb2[1] > rgb2[2], rgb2
    print("  PASS: card green when all checked")

The signal a packer actually waits for is the one above that: a whole trip done. So a trip greens the same way, once every card it gathers has.

@testcase
def test_trip_green_when_all_checked(page):
    """A focused trip turns green once all its things are checked."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for thing in ("gants", "bonnet"):
        add_thing(page, thing, "ski")
    plan_trip(page, "week-end", ["ski"])
    page.get_by_role("button", name="Terminé").click()
    art = page.locator("article.trip")
    for thing in ("gants", "bonnet"):
        page.get_by_role("checkbox", name=thing).click()
    rgb = [int(x) for x in re.findall(r"\d+", art.evaluate("el => getComputedStyle(el).backgroundColor"))[:3]]
    assert rgb[1] > rgb[0] and rgb[1] > rgb[2], rgb   # greenish
    page.get_by_role("button", name="Masquer les choses cochées").click()   # green stands with checked hidden
    rgb2 = [int(x) for x in re.findall(r"\d+", art.evaluate("el => getComputedStyle(el).backgroundColor"))[:3]]
    assert rgb2[1] > rgb2[0] and rgb2[1] > rgb2[2], rgb2
    print("  PASS: trip green when all checked")

Once a trip is over the ticks have to be cleared for the next. That is undoing a lot of taps by hand, so there is a reset: one in the toolbar for the whole list, and one on each card and trip heading — the latter shown only while that scope still holds a tick — to clear just its own.

@testcase
def test_uncheck_all(page):
    """One toolbar control clears every tick at once."""
    clear_state(page)
    edit(page)
    for name in ("plage", "ski"):
        add_tag(page, name)
    for thing, sec in (("crème", "plage"), ("gants", "ski")):
        add_thing(page, thing, sec)
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("checkbox", name="crème").click()
    page.get_by_role("checkbox", name="gants").click()
    page.get_by_role("button", name="Tout décocher").click()
    expect(page.get_by_role("checkbox", name="crème")).not_to_be_checked()
    expect(page.get_by_role("checkbox", name="gants")).not_to_be_checked()
    print("  PASS: uncheck all")

A reset wipes ticks you might want back, so none passes silently: the whole-list Tout décocher and every per-scope ↺ alike announce what they cleared, each with an Annuler that restores those ticks in one step.

@testcase
def test_uncheck_all_notifies_and_undoes(page):
    """Tout décocher announces itself; its Annuler restores every tick."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    for thing in ("gants", "bonnet"):
        add_thing(page, thing, "ski")
    page.get_by_role("button", name="Terminé").click()
    for thing in ("gants", "bonnet"):
        page.get_by_role("checkbox", name=thing).click()
    page.get_by_role("button", name="Tout décocher").click()
    expect(page.get_by_role("checkbox", name="gants")).not_to_be_checked()
    assert page.get_by_text("Tout décoché").is_visible()
    page.get_by_role("status").get_by_role("button", name="Annuler").click()
    expect(page.get_by_role("checkbox", name="gants")).to_be_checked()
    expect(page.get_by_role("checkbox", name="bonnet")).to_be_checked()
    print("  PASS: uncheck all notifies and undoes")

@testcase
def test_uncheck_tag(page):
    """A card's reset clears only its ticks, leaving the other cards."""
    clear_state(page)
    edit(page)
    for name in ("plage", "ski"):
        add_tag(page, name)
    for thing, tag in (("crème", "plage"), ("gants", "ski")):
        add_thing(page, thing, tag)
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("checkbox", name="crème").click()
    page.get_by_role("checkbox", name="gants").click()
    plage = page.locator("section").filter(has=page.get_by_role("heading", name="plage"))
    plage.get_by_role("button", name="Décocher l'étiquette").click()
    expect(page.get_by_role("checkbox", name="crème")).not_to_be_checked()
    expect(page.get_by_role("checkbox", name="gants")).to_be_checked()
    assert page.get_by_text("Étiquette décochée").is_visible()
    page.get_by_role("status").get_by_role("button", name="Annuler").click()
    expect(page.get_by_role("checkbox", name="crème")).to_be_checked()   # restored
    print("  PASS: uncheck tag")

@testcase
def test_uncheck_trip(page):
    """A trip's reset clears the ticks of everything inside it."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    add_thing(page, "gants", "ski")
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "week-end", ["ski"])
    page.get_by_role("checkbox", name="gants").click()
    page.get_by_role("button", name="Décocher la sortie").click()
    expect(page.get_by_role("checkbox", name="gants")).not_to_be_checked()
    assert page.get_by_text("Sortie décochée").is_visible()
    page.get_by_role("status").get_by_role("button", name="Annuler").click()
    expect(page.get_by_role("checkbox", name="gants")).to_be_checked()   # restored
    print("  PASS: uncheck trip")

A thing or a tag can be renamed — a pencil opens a small field prefilled with the current name. Renaming keeps the same thing: its id and every membership keyed on it are untouched, only the label it shows changes.

@testcase
def test_rename_item(page):
    """A thing can be renamed and shows its new label."""
    clear_state(page)
    edit(page)
    add_thing(page, "passeprot")
    page.locator("li").filter(has_text="passeprot").get_by_role(
        "button", name="Renommer").click()
    assert page.get_by_role("dialog").count() == 0        # inline, not a modal
    box = page.locator(".rename-input")
    box.press("ControlOrMeta+a"); box.press_sequentially("passeport")
    box.press("Enter")
    assert page.get_by_text("passeport").is_visible()
    assert page.get_by_text("passeprot").count() == 0
    print("  PASS: rename item")

A tag renames the same way, by name — the trip a card stands for being a tag too, the pencil in its heading is the same one.

@testcase
def test_rename_tag(page):
    """A tag can be renamed."""
    clear_state(page)
    edit(page)
    add_tag(page, "soliel")
    page.locator("section").filter(
        has=page.get_by_role("heading", name="soliel")).get_by_role(
        "button", name="Renommer").click()
    assert page.get_by_role("dialog").count() == 0
    box = page.locator(".rename-input")
    box.press("ControlOrMeta+a"); box.press_sequentially("soleil")
    box.press("Enter")
    assert page.get_by_role("heading", name="soleil").is_visible()
    print("  PASS: rename tag")

A trip is renamed by the same pencil, and it must come out of it still a trip — a correction to a name is not a decision to demote a journey to a topic.

@testcase
def test_rename_trip(page):
    """A trip can be renamed from its focused view, and stays a trip."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "week-emd", ["ski"])
    edit(page)
    page.locator("article.trip > h2").get_by_role("button", name="Renommer").click()
    assert page.get_by_role("dialog").count() == 0
    box = page.locator(".rename-input")
    box.press("ControlOrMeta+a"); box.press_sequentially("week-end")
    box.press("Enter")
    assert page.get_by_role("heading", name="week-end").is_visible()
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Retour").click()
    assert page.locator(".chip", has_text="week-end").is_visible()   # still a trip, still a chip
    print("  PASS: rename trip")

A tag cannot borrow a name another tag already carries — two must never look alike, since each is folded and gathered on its own. That rename is simply refused, leaving the name as it was.

@testcase
def test_rename_tag_to_taken_name_refused(page):
    """Renaming a tag to a name another tag carries is refused."""
    clear_state(page)
    edit(page)
    for name in ("tente", "sac"):
        add_tag(page, name)
    page.locator("section").filter(
        has=page.get_by_role("heading", name="sac")).get_by_role(
        "button", name="Renommer").click()
    box = page.locator(".rename-input")
    box.press("ControlOrMeta+a"); box.press_sequentially("tente")
    box.press("Enter")
    assert page.get_by_role("heading", name="tente").count() == 1   # no second "tente"
    assert page.get_by_role("heading", name="sac").count() == 1     # "sac" kept its name
    print("  PASS: rename tag to taken name refused")

A thing is different: renaming one to a name another thing already carries does not refuse — it links them. Two things under two tags that turn out to be the same thing become one, shown under both, ticked as a single thing. The renamed thing is folded into its namesake: every tag it carried, and whether it was packed, passes to the survivor, and it is gone.

@testcase
def test_rename_to_twin_links_items(page):
    """Renaming a thing to another thing's name links them: one thing, both tags."""
    clear_state(page)
    edit(page)
    for thing, tag in (("gourde", "rando"), ("bouteille", "plage")):
        add_tag(page, tag)
        add_thing(page, thing, tag)
    page.locator("section").filter(
        has=page.get_by_role("heading", name="plage")).locator("li").filter(
        has_text="bouteille").get_by_role("button", name="Renommer").click()
    box = page.locator(".rename-input")
    box.press("ControlOrMeta+a"); box.press_sequentially("gourde")
    box.press("Enter")
    rando = page.locator("section").filter(has=page.get_by_role("heading", name="rando"))
    plage = page.locator("section").filter(has=page.get_by_role("heading", name="plage"))
    assert rando.get_by_text("gourde").count() == 1
    assert plage.get_by_text("gourde").count() == 1     # carrying both tags
    assert page.get_by_text("bouteille").count() == 0   # the namesake folded in
    print("  PASS: rename to twin links items")

A cross takes a thing out of the card it is shown in — dropping that one tag — and leaves every other tag it carries. Only when there is no tag to drop — an untagged thing, a card in the catalog — does the cross delete outright, sweeping every membership keyed on it so nothing dangles and a later namesake starts fresh. It does not ask first: it acts and raises a notification, and undo takes it back.

A thing typed loose and no longer wanted is the plain case: the cross ends it.

@testcase
def test_remove_item(page):
    """Removing a thing takes it off the list."""
    clear_state(page)
    edit(page)
    add_thing(page, "tente")
    page.locator("li").filter(has_text="tente").get_by_role(
        "button", name="Supprimer").click()
    assert page.locator(".items").get_by_text("tente").count() == 0
    print("  PASS: remove item")

A tag in the catalog is under no other, so the cross on its card ends the tag too.

@testcase
def test_remove_tag(page):
    """Removing a tag takes its card off the list."""
    clear_state(page)
    edit(page)
    add_tag(page, "piscine")
    page.locator("section").filter(
        has=page.get_by_role("heading", name="piscine")).get_by_role(
        "button", name="Supprimer").click()
    assert page.get_by_role("heading", name="piscine").count() == 0
    print("  PASS: remove tag")

A trip is the same, and ending one must not take the packing list down with it: what it gathered was other people’s tags all along.

@testcase
def test_remove_trip(page):
    """Removing a trip takes its chip off the list."""
    clear_state(page)
    edit(page)
    add_tag(page, "balade")
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "dimanche", ["balade"])
    edit(page)
    page.locator("article.trip > h2").get_by_role("button", name="Supprimer").click()
    assert page.locator(".chip", has_text="dimanche").count() == 0
    print("  PASS: remove trip")

The cross on a thing shown under several tags only takes it out of the one you crossed — the others keep it.

@testcase
def test_remove_keeps_linked(page):
    """Removing a thing under one tag leaves it under the others."""
    clear_state(page)
    edit(page)
    for name in ("soleil", "ski"):
        add_tag(page, name)
    for tag in ("soleil", "ski"):
        add_thing(page, "crème solaire", tag)
    soleil = page.locator("section").filter(
        has=page.get_by_role("heading", name="soleil"))
    soleil.locator("li").filter(has_text="crème solaire").get_by_role(
        "button", name="Supprimer").click()
    assert page.locator(".items").get_by_text("crème solaire").count() == 1
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    assert ski.get_by_text("crème solaire").is_visible()
    print("  PASS: remove keeps linked")

A thing crossed off its only tag is not deleted — it drops to Sans étiquette, out but still there.

@testcase
def test_remove_takes_item_out(page):
    """Removing a thing under its only tag drops it to Sans étiquette."""
    clear_state(page)
    edit(page)
    add_tag(page, "soleil")
    add_thing(page, "lunettes", "soleil")
    page.locator("section").filter(
        has=page.get_by_role("heading", name="soleil")).locator("li").filter(
        has_text="lunettes").get_by_role("button", name="Supprimer").click()
    assert page.locator(".toast").is_visible()               # getting-out is notified, not silent
    assert page.locator(".items").get_by_text("lunettes").count() == 1
    sans = page.locator("section").filter(
        has=page.get_by_role("heading", name="Sans étiquette"))
    assert sans.get_by_text("lunettes").is_visible()
    print("  PASS: remove takes item out")

Crossing a card off a trip, in its focused view, only stops the trip gathering it — the tag stays in the catalog, since every tag lives there whatever trips also gather it.

@testcase
def test_remove_takes_tag_out_of_trip(page):
    """Removing a card from a focused trip leaves the tag in the catalog, not gone."""
    clear_state(page)
    edit(page)
    add_tag(page, "ski")
    page.get_by_role("button", name="Terminé").click()
    plan_trip(page, "week-end", ["ski"])
    edit(page)
    we = page.locator("article.trip")
    we.get_by_role("heading", name="ski").get_by_role(
        "button", name="Supprimer").click()
    assert we.get_by_role("heading", name="ski").count() == 0
    page.get_by_role("button", name="Retour").click()
    assert page.get_by_role("heading", name="ski").is_visible()
    print("  PASS: remove takes tag out of trip")

Deleting a tag outright (crossing off a catalog card) keeps the things it named — they fall back to Sans étiquette — and leaves no membership to resurrect a later namesake into.

@testcase
def test_deleting_tag_keeps_items(page):
    """Deleting a tag keeps its things (untagged), with no resurrection."""
    clear_state(page)
    edit(page)
    add_tag(page, "soleil")
    add_thing(page, "crème", "soleil")
    page.get_by_role("heading", name="soleil").get_by_role(
        "button", name="Supprimer").click()
    assert page.locator(".items").get_by_text("crème").count() == 1
    add_tag(page, "soleil")
    soleil = page.locator("section").filter(
        has=page.get_by_role("heading", name="soleil"))
    assert soleil.get_by_text("crème").count() == 0
    print("  PASS: deleting tag keeps items")

A delete does not ask — it acts, then says so with a notification whose Annuler undoes it, so a mistaken cross is one tap from coming back.

@testcase
def test_delete_notifies_and_undoes(page):
    """A delete happens at once with a notification; its Annuler brings it back."""
    clear_state(page)
    edit(page)
    add_thing(page, "passeport")
    page.locator("li").filter(has_text="passeport").get_by_role(
        "button", name="Supprimer").click()
    assert page.locator(".items").get_by_text("passeport").count() == 0   # gone at once
    toast = page.locator(".toast")
    assert toast.is_visible()                                             # with a notification
    toast.get_by_role("button", name="Annuler").click()
    assert page.locator(".items").get_by_text("passeport").count() == 1   # undo restores it
    print("  PASS: delete notifies and undoes")

Everything above rests on one decision, made twice over: a name is an identity. The same label typed a second time must land on the thing you already have, and a tag named twice must be one tag, or the list quietly fills with near-duplicates nobody asked for. So an id is its name folded down — lowercased, unaccented, punctuation collapsed — and that is why typing a thing you already have under a second card gives it that card’s tag instead of a twin. Each change also carries a sentence naming what it did, which is what the history screen reads back and what keeps two distinct steps from coalescing into one.

A tag made by the planner is marked as a trip, and nothing else about it differs. And a tick is the smallest write of all: one entry in checked, keyed by the thing, so it holds wherever the thing shows up.

function slug(s){
    return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
            .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}

function commit(message){ doc.commit({ message }); }   // the message names the step in the history

function addItem(label, tagId){
    label = (label || '').trim();
    if(!label) return;
    const id = slug(label);
    items.set(id, { label });
    if(tagId) tagged.set(id + ':' + tagId, 1);
    commit('« ' + label + ' » ajouté');
}

function addTag(name, trip){
    name = (name || '').trim();
    if(!name) return '';
    const id = slug(name);
    tags.set(id, trip ? { name, trip: 1 } : { name });
    commit((trip ? 'sortie « ' : 'étiquette « ') + name + ' » ajoutée');
    return id;
}

function toggleTag(itemId, tagId){
    const k = itemId + ':' + tagId;
    const label = items.get(itemId)?.label, name = tags.get(tagId)?.name;
    if(tagged.get(k)){ tagged.delete(k); commit('« ' + label + " » n'est plus « " + name + ' »'); }
    else { tagged.set(k, 1); commit('« ' + label + ' » étiqueté « ' + name + ' »'); }
}

function toggleCheck(itemId){
    const nowChecked = !checked.get(itemId);
    if(nowChecked && hideChecked && !editMode){ collapseThenCheck(itemId); return; }
    applyCheck(itemId, nowChecked);
    flashItem(itemId);
}

function applyCheck(itemId, on){
    on ? checked.set(itemId, 1) : checked.delete(itemId);
    commit('« ' + items.get(itemId)?.label + ' » ' + (on ? 'coché' : 'décoché'));
}

function uncheck(ids, message){ ids.forEach(id => checked.delete(id)); commit(message); }
function uncheckAll(){
    const ids = Object.keys(checked.toJSON());
    if(!ids.length) return;
    uncheck(ids, 'Tout décoché');
    notify('Tout décoché');
}

Adding is the one act reached from four places — the main screen’s standing field, a card’s +, a long press on a card’s title, the + Étiquette at the foot — and they are one row wearing four hats rather than four rows. What differs between them is only where the result lands, so that is all a caller passes: a tag’s id, or the sentinel :untagged or :tag. A colon can’t occur in a slug, so a sentinel can never collide with a real id. Only one contextual field is up at a time — two open at once would make it a guess where the next word is going — and opening one unfolds its card so the field is actually on screen.

function openAdd(target){
    adding = (adding === target) ? null : target;
    addText = '';
    if(adding && target !== ':untagged' && target !== ':tag') folded.delete('tag:' + target);
    paint();
    const box = document.querySelector('.section-add-input');
    if(box) box.focus();
}

function closeAdd(){ adding = null; addText = ''; paint(); }

submitAdd reads the field and routes it: to addTag, or to addItem carrying that tag or none. It empties the field before it routes, because adding commits the document and that repaints at once — clear first and the fresh paint leaves an empty field, not a lingering suggestion for the name just consumed.

function submitAdd(target, sel){
    const box = document.querySelector(sel || '.section-add-input');
    const val = box.value;
    box.value = ''; addText = '';
    if(target === ':tag') addTag(val);
    else addItem(val, target === ':untagged' ? '' : target);
    box.focus();
}

A document-level pointerdown watches for the dismissing tap: while a field is open in the default mode, a press that lands outside the .card-add block — the row and its suggestions — closes it, so tapping a suggestion never dismisses the field; held to that mode, since edit mode’s own /+/ already toggles the field and would otherwise fight the same press.

document.addEventListener('pointerdown', e => {
    if(adding && !editMode && !e.target.closest('.section-add')) closeAdd();
});

The field does two things more than take a name. First, it completes against what the list already holds. Because a thing’s id is its normalized label, two spellings of one thing — crème solaire typed once, creme solaire the next time — slug apart and become two near-duplicates instead of one. So as you type, addSuggestions offers the current labels your text is slugging toward, steering a thing you are adding again back onto its first spelling rather than a fresh variant, and tapping one fills the field and keeps the focus so the phone keyboard stays up and you can carry straight on (pickSuggestion). The offer is drawn in the page rather than handed to a native <datalist>: a browser’s completion popup is dismissed the instant you scroll to read it, and a phone holding a dozen things needs suggestions you can scroll through and tap. Only the thing fields complete — the :tag field has no catalog to match against — and an empty field offers nothing, since there is no spelling yet to steer.

@testcase
def test_add_completes_against_known_things(page):
    """Typing in a card's add field offers matching known labels, and tapping one fills it."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "crème solaire", "sac")   # the card's field stays open after adding
    field = page.get_by_placeholder("Nouvelle chose")
    field.fill("crem")
    suggestion = page.locator(".section-suggest").get_by_role("button", name="crème solaire")
    assert suggestion.is_visible()
    suggestion.click()
    assert field.input_value() == "crème solaire"
    print("  PASS: add completes against known things")

function addSuggestions(){
    const q = slug(addText);
    if(!q) return '';                                       // an empty field has no spelling to steer
    const hits = Object.values(items.toJSON()).filter(v => slug(v.label).includes(q));
    return hits.length ? html`
      <ul class="section-suggest">
        ${hits.map(v => html`<li><button type="button" onclick=${() => pickSuggestion(v.label)}>${v.label}</button></li>`)}
      </ul>` : '';
}

function pickSuggestion(label){
    const box = document.querySelector('.section-add-input');
    if(box){ box.value = label; box.focus(); }
    addText = label; paint();
}

Steering back onto the first spelling is what makes the second typing land on the same thing, and that is what turns the add field into a tagging gesture: type a thing you already have under a second card and it gains that card’s tag rather than spawning a near-twin beside it.

@testcase
def test_retyping_a_thing_tags_it_here(page):
    """A known label typed under another tag gives it that tag — no twin is made."""
    clear_state(page)
    edit(page)
    for name in ("hiver", "ski"):
        add_tag(page, name)
    add_thing(page, "gants", "hiver")
    add_thing(page, "gants", "ski")
    hiver = page.locator("section").filter(has=page.get_by_role("heading", name="hiver"))
    ski = page.locator("section").filter(has=page.get_by_role("heading", name="ski"))
    assert ski.get_by_text("gants").is_visible()
    assert hiver.get_by_text("gants").is_visible()     # still there — carrying both
    assert page.get_by_text("gants").count() == 2      # two placements of one thing
    print("  PASS: retyping a thing tags it here")

These pieces come together in addField, the contextual row itself, rebuilt on every keystroke. Reading addText live is why it repaints each time; like the filter it binds no value back, so the input’s own text and caret survive the repaint untouched, while the suggestion list under it rebuilds against the new text.

function addField(target, placeholder, submitLabel){
    const thing = target !== ':tag';   // a tag name has no catalog to complete against
    return html`
      <div class="section-add">
        <div class="add">
          <input class="section-add-input" placeholder=${placeholder}
                 oninput=${e => { addText = e.target.value; paint(); }}
                 onkeydown=${e => e.key === 'Enter' ? submitAdd(target)
                                : e.key === 'Escape' ? closeAdd() : null}>
          <button aria-label=${submitLabel} onclick=${() => submitAdd(target)}>+</button>
        </div>
        ${thing ? addSuggestions() : ''}
      </div>`;
}

The main screen’s field is that same row minus the machinery that only a contextual one needs: it never opens or closes, so it takes no adding target, and it never reports to a card, so it needs no suggestion list fighting the catalog underneath. What it does need is to know where it is: on the catalog a thing typed there carries nothing, and inside a trip it carries that trip, because a thing you think of while packing is a thing for this trip.

function mainAddField(){
    return html`
      <div class="add main-add">
        <input class="main-add-input" placeholder="Ajouter une chose" aria-label="Ajouter une chose"
               onkeydown=${e => { if(e.key === 'Enter') submitAdd(focusId || ':untagged', '.main-add-input'); }}>
        <button aria-label="Ajouter la chose"
                onclick=${() => submitAdd(focusId || ':untagged', '.main-add-input')}>+</button>
      </div>`;
}

The long press itself is a small gesture machine over the title. longPressStart arms a timer on the press; if it survives half a second the hold has fired and its action runs, otherwise a release or a cancel (longPressCancel) — a scroll turning the touch into a pan — disarms it first. longPressed reports and clears whether a hold just fired, so the tap-to-fold on the same title can bow out when the press was really a long one.

let pressTimer, pressFired = false;
function longPressStart(action){
    pressFired = false;
    clearTimeout(pressTimer);
    pressTimer = setTimeout(() => { pressFired = true; action(); }, 500);
}
function longPressCancel(){ clearTimeout(pressTimer); }
function longPressed(){ const was = pressFired; pressFired = false; return was; }

Both membership maps key on a pair joined by a single colon — itemId:tagId, tagId:tagId. Two helpers split a key into its halves; ids are colon-free slugs, so indexOf finds the join unambiguously. The rename merge, the delete sweep, and the view’s derivation all lean on them.

function before(k){ return k.slice(0, k.indexOf(':')); }
function after(k){ return k.slice(k.indexOf(':') + 1); }

Renaming keeps the same thing — it rewrites only the label, leaving the id and every membership keyed on it untouched. It happens in place, no modal: the label becomes a field. startEdit records which kind:id is open and seeds editingText, then paints and focuses the field; editingText holds the in-progress text so a repaint mid-edit does not lose it. Enter or blur commits, Escape abandons. Committing repaints, which pulls the field out and fires a trailing blur; a guard on the now-cleared editing makes that second call a no-op, so a rename runs once. A tag rename is refused if nameTaken — another tag already carries that name — so no two ever look alike. A thing is the exception: naming it after another thing does not refuse but links the two. mergeItems folds the renamed thing into its namesake — every tag it carried is re-keyed onto the survivor, its tick carried over if it was packed, and the thing itself deleted — so the two become one, shown under every card either appeared in.

function nameTaken(map, id, name){
    return Object.entries(map.toJSON()).some(([i, v]) => i !== id && (v.label || v.name) === name);
}
function mergeItems(from, into){
    for(const k of Object.keys(tagged.toJSON()))
        if(before(k) === from){ tagged.set(into + ':' + after(k), 1); tagged.delete(k); }
    if(checked.get(from)) checked.set(into, 1);
    checked.delete(from);
    items.delete(from);
    commit('« ' + (items.get(into)?.label || into) + ' » fusionné');
}
function renameItem(id, label){
    label = (label || '').trim();
    if(!label) return;
    const twin = Object.keys(items.toJSON()).find(i => i !== id && items.get(i).label === label);
    if(twin) mergeItems(id, twin);
    else { items.set(id, { label }); commit('renommé « ' + label + ' »'); }
}
function renameTag(id, name){
    name = (name || '').trim();
    if(!name || nameTaken(tags, id, name)) return;
    tags.set(id, { ...tags.get(id), name });   // keep the trip flag: a rename is not a change of kind
    commit('étiquette renommée « ' + name + ' »');
}

function startEdit(key, current){
    editing = key; editingText = current; paint();
    const box = document.querySelector('.rename-input');
    if(box){ box.focus(); box.select(); }
}

function commitEdit(rename, id){
    if(editing === null) return;   // Enter already committed; ignore the trailing blur
    const text = editingText; editing = null; rename(id, text); paint();
}

function cancelEdit(){ editing = null; paint(); }

Deleting a thing outright removes it and then sweeps every tagged key that names it, so no membership dangles and a later namesake (same slug) starts fresh. Deleting a tag sweeps only the memberships, on both sides of gathers, so the things it named survive — untagged, back in the quick list — rather than vanishing with it.

function removeKeys(map, part, id){
    for(const k of Object.keys(map.toJSON())) if(part(k) === id) map.delete(k);
}

function deleteItem(id, msg){
    items.delete(id); checked.delete(id);
    removeKeys(tagged, before, id);
    commit(msg);
}

function deleteTag(id, msg){
    tags.delete(id);
    removeKeys(tagged, after, id);
    removeKeys(gathers, before, id);   // the tags it gathered
    removeKeys(gathers, after, id);    // and the trips that gathered it
    commit(msg);
}

Taking-out is dropping a single membership — a thing’s tag, or a tag a trip gathers — leaving everything else untouched. removeItemHere drops the tag of the card the thing was crossed off in, and only when the thing carried nothing does it delete the thing outright. removeTagHere drops a tag from the trip it is shown inside, or deletes a catalog one. Neither stops to ask: each acts at once and raises a notification naming what it did, and the toolbar’s undo (one commit, one step) puts it back. So crossing a thing off under one tag, while it carries another, only takes it out of the first — and even an outright delete is one tap from return.

function removeItemHere(id, name, srcTag){
    if(srcTag){ const m = '« ' + name + ' » retiré'; tagged.delete(id + ':' + srcTag); commit(m); notify(m); }
    else { const m = '« ' + name + ' » supprimé'; deleteItem(id, m); notify(m); }
}

function removeTagHere(id, name, srcTrip){
    if(srcTrip){ const m = '« ' + name + ' » retiré de la sortie'; gathers.delete(srcTrip + ':' + id); commit(m); notify(m); }
    else { const m = '« ' + name + ' » supprimé'; deleteTag(id, m); notify(m); }
}

tagged is what puts a thing under a card: collect runs down the map, buckets the thing (the before half) under the tag’s id (the after half) and marks it carried, so anything left over is untagged. Within a bucket the things are then sorted by their manual order, falling back to the map’s own key order for any not yet placed by hand — so a card left untouched keeps whatever order the map gives, and one arranged by hand honours that arrangement.

function collect(tgd, its, chk, carried, ord){
    const byTag = {};
    for(const k of Object.keys(tgd.toJSON())){
        const id = before(k);
        if(!its[id]) continue;
        (byTag[after(k)] ||= []).push({ id, label: its[id].label, done: !!chk[id] });
        carried[id] = true;
    }
    for(const t in byTag){
        byTag[t].forEach((it, i) => { it.pos = ord.get('i:' + it.id + ':' + t) ?? i; });
        byTag[t].sort((a, b) => a.pos - b.pos);
    }
    return byTag;
}

The view has two faces: a catalog where the whole list is curated — a card per ordinary tag with the things carrying it — and, per trip, the cards of the tags that trip gathers and the things carrying the trip’s own name, for its focused packing view. Both fall out of a single collect pass, since a thing carrying a trip’s name and a thing carrying ski are the same fact in the same map. What that pass also records is which things are carried at all, so the leftovers are exactly the untagged ones — the quick list. A loop then reads which tags each trip gathers. The catalog is every non-trip tag, in its manual order (fallback: the map’s own key order), the same sort collect gives the things under a tag.

function buildModel(){
    // a preview reads a detached fork; everything else reads the live doc
    const src = previewDoc || doc;
    const items = src.getMap('items'), tags = src.getMap('tags'), checked = src.getMap('checked'),
          tagged = src.getMap('tagged'), gathers = src.getMap('gathers'), order = src.getMap('order');
    const its = items.toJSON(), tgs = tags.toJSON(), chk = checked.toJSON(), carried = {};
    const ofTag = collect(tagged, its, chk, carried, order);
    const card = (id) => ({ id, name: tgs[id]?.name, items: ofTag[id] || [] });

    const gathered = {};
    for(const k of Object.keys(gathers.toJSON()))
        if(tgs[after(k)]) (gathered[before(k)] ||= []).push(after(k));
    const trips = Object.entries(tgs).filter(([, v]) => v.trip).map(([id, v]) =>
        ({ id, name: v.name, tags: (gathered[id] || []).map(card), items: ofTag[id] || [] }));
    const catalog = Object.keys(tgs).filter(id => !tgs[id].trip)
        .map((id, i) => ({ id, pos: order.get('t:' + id) ?? i }))
        .sort((a, b) => a.pos - b.pos)
        .map(x => card(x.id));
    const untagged = Object.entries(its).filter(([id]) => !carried[id])
        .map(([id, v]) => ({ id, label: v.label, done: !!chk[id] }));
    return { trips, catalog, untagged };
}

The filterBar is a single search field bound to filter, and matchesFilter is the word test it feeds — the one shared by the catalog, the focused trip, and the planner, so all three narrow alike. Under the hood, folding accents is normalize('NFD') with the combining marks stripped, so crème and creme reduce to one form before the words are tested. The field binds no value — the input node is reused across repaints, so its text (and the caret) survive on their own; binding filter back would only fight the caret.

A tag is added from the foot of the list: addTagActuator is a + Étiquette that, tapped, reveals the same inline field the per-card + uses, keyed on the :tag sentinel. It sits at the bottom, in edit mode only, so making a tag is a deliberate reach — the everyday act is carrying one that exists.

function foldText(s){ return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); }

function matchesFilter(label, query){
    const hay = foldText(label);
    return foldText(query).split(/\s+/).filter(Boolean).every(word => hay.includes(word));
}

function filterBar(){
    return html`
      <div class="filter">
        <input type="search" class="filter-input" placeholder="Filtrer" aria-label="Filtrer"
               oninput=${e => { filter = e.target.value; paint(); }}>
      </div>`;
}

function addTagActuator(){
    return html`
      <div class="add-section">
        <button class="add-section-btn" aria-label="Nouvelle étiquette"
                onclick=${() => openAdd(':tag')}>+ Étiquette</button>
        ${adding === ':tag' ? addField(':tag', 'Nouvelle étiquette', "Créer l'étiquette") : ''}
      </div>`;
}

Every add row in the app is that same .add line, so one rule decides whether any of them fits a narrow phone: the text input takes all the slack and may shrink to nothing, while the small + never shrinks. The button is the part that must not be reachable only by scrolling sideways — it is the one that finishes the act. The + Étiquette actuator at the foot is a plain dashed button, quiet until reached for.

.add{display:flex;gap:8px;padding:8px 16px;max-width:480px;margin:0 auto}
.add input{flex:1 1 8rem;min-width:0;padding:10px 12px;border:1px solid #ccc;border-radius:8px;font:inherit}
.add button{flex:0 0 auto;padding:10px 16px;border:0;border-radius:8px;background:#1b1d2e;color:#fff;font:inherit;cursor:pointer;white-space:nowrap}
.main-add{padding:8px 16px 4px}
.section-suggest{list-style:none;max-width:480px;margin:2px auto 0;padding:0 16px;display:flex;flex-direction:column;gap:4px}
.section-suggest button{display:block;width:100%;text-align:left;padding:9px 12px;border:1px solid #e0e0e6;border-radius:8px;background:#fff;color:inherit;font:inherit;cursor:pointer}
.section-suggest button:hover{background:#f2f3f7}
.filter{padding:4px 16px;max-width:480px;margin:0 auto}
.filter-input{width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid #ccc;border-radius:8px;font:inherit}
.add-section{max-width:480px;margin:8px auto;padding:0 16px}
.add-section-btn{padding:8px 14px;border:1px dashed #c4c6d4;border-radius:8px;background:transparent;color:#8a8ea5;font:inherit;cursor:pointer}

Renaming and removing are the same pair of gestures wherever they are offered — on a thing, on a card, on a trip — so they are built once and handed the closures for whichever it is. The rename happens in place rather than in a modal: the name simply becomes a field, and Enter or a tap elsewhere settles it while Escape abandons it.

function toggleSelect(k){ selected.has(k) ? selected.delete(k) : selected.add(k); paint(); }

function editButtons(onRename, onRemove){
    return html`
      <button class="row-btn" aria-label="Renommer" onclick=${onRename}>✎</button>
      <button class="row-btn" aria-label="Supprimer" onclick=${onRemove}>✕</button>`;
}

function renameField(rename, id){
    return html`<input class="rename-input" value=${editingText}
      oninput=${e => editingText = e.target.value}
      onkeydown=${e => e.key === 'Enter' ? commitEdit(rename, id)
                     : e.key === 'Escape' ? cancelEdit() : null}
      onblur=${() => commitEdit(rename, id)}>`;
}

A held row answers for its thing as a whole, and what a thing is is the tags it carries — so that is what the hold puts in front of you, one chip per tag, pressed for the ones it has. The trips are left out. A chip that quietly sent a thing off on a journey would be the one tap here whose consequence is not on the screen you are looking at; a trip is composed in the planner, where you can see what it is becoming.

function tagChips(itemId){
    const carried = new Set(Object.keys(tagged.toJSON()).filter(k => before(k) === itemId).map(after));
    return html`<div class="row-tags">${Object.entries(tags.toJSON())
      .filter(([, v]) => !v.trip)
      .map(([id, v]) => html`
        <button class="tagchip" aria-pressed=${carried.has(id) ? 'true' : 'false'}
                onclick=${() => toggleTag(itemId, id)}>${v.name}</button>`)}</div>`;
}

What a row shows besides its label is decided before any of it is drawn, and three questions settle it. Is this row answerable at all — because we are curating, or because it is the one being held, or because it was picked for a batch and its box must not vanish out from under the pick? Can it be dragged — which needs a card to be dragged inside, and needs the packed rows still on screen to drop between? And which of the list is on screen at all, once hiding has taken the packed ones out and the arrival has floated the stragglers up.

function rowsOnScreen(list, reorderable){
    const kept = (!editMode && hideChecked) ? list.filter(it => !it.done) : list;
    return (floatSet && !reorderable)   // arrival left a floatSet: lift its stragglers up, but never mid-drag
        ? [...kept].sort((a, b) => floatSet.has(b.id) - floatSet.has(a.id)) : kept;
}

The row itself then falls out of those answers. Its two gestures land on two targets — the box ticks, the label and the box both hold — and the held row gains its grip, its selection box, its rename and remove pair, and its chips. The selection key ties the thing to the card it was picked in, so picking it under one tag does not pick it under every other; the rename key is the thing alone, since a name is not per-card.

function itemRows(list, srcTag){
    const skOf = it => it.id + '|' + (srcTag || '');
    const activeHere = !!srcTag && list.some(it => skOf(it) === activeItem);
    const reorderable = (editMode || (activeHere && !hideChecked)) && !!srcTag;
    return html`<ul class=${'items' + (reorderable ? ' reorder-list' : '') + (activeHere ? ' solo' : '')}
                     data-reorder=${reorderable ? 'tag:' + srcTag : null}>${
      rowsOnScreen(list, reorderable).map((it, i) => {
        const ek = 'item:' + it.id, sk = skOf(it);
        const controls = editMode || sk === activeItem || selected.has(sk);
        const grip = reorderable && (editMode || sk === activeItem);
        return html`
      <li class=${(it.done ? 'done' : '') + (selected.has(sk) ? ' selected' : '') + (reorderable ? ' reorder-item' : '') + (sk === activeItem ? ' active' : '')}
          data-idx=${reorderable ? i : null} data-item=${it.id}>
        ${editing === ek ? renameField(renameItem, it.id) : html`
          ${grip ? html`<span class="reorder-grip" aria-label="Réordonner">⠿</span>` : ''}
          ${controls ? html`
            <input type="checkbox" class="select" aria-label="Sélectionner"
                .checked=${selected.has(sk)} onchange=${() => toggleSelect(sk)}>` : ''}
          <span class="check" role="checkbox" aria-checked=${it.done ? 'true' : 'false'} aria-label=${it.label}
                onpointerdown=${editMode ? null : () => longPressStart(() => activate(sk))}
                onpointerup=${editMode ? null : longPressCancel}
                onpointercancel=${editMode ? null : longPressCancel}
                onclick=${() => { if(longPressed()) return; toggleCheck(it.id); }}></span>
          <span class="label"
                onpointerdown=${editMode ? null : () => longPressStart(() => activate(sk))}
                onpointerup=${editMode ? null : longPressCancel}
                onpointercancel=${editMode ? null : longPressCancel}>${it.label}</span>
          ${controls ? editButtons(() => startEdit(ek, it.label),
                                   () => removeItemHere(it.id, it.label, srcTag)) : ''}`}
        ${sk === activeItem ? tagChips(it.id) : ''}
      </li>`; })}</ul>`;
}

So the two gestures fall on two targets: a tap on the label leaves the tick alone — only the box ticks — which is what keeps a careless brush from packing a thing.

@testcase
def test_tapping_label_does_not_tick(page):
    """Only the checkbox ticks a thing; a tap on its label does not."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    row = sac.locator("li").filter(has=page.get_by_role("checkbox", name="corde"))
    row.get_by_text("corde").click()                                       # tap the label
    expect(page.get_by_role("checkbox", name="corde")).not_to_be_checked() # leaves the tick alone
    row.get_by_role("checkbox", name="corde").click()                      # tap the box
    expect(page.get_by_role("checkbox", name="corde")).to_be_checked()     # ticks
    print("  PASS: tapping label does not tick")

The hold has to earn its reveal without stealing the box’s tap. Hold a thing — its label or its box — and its controls appear; tap the box and it still just ticks, the hold and the tap sharing that box the way a hold and a tap already share a card title, only one acting on a given press.

@testcase
def test_longpress_item_reveals_controls(page):
    """A hold reveals a thing's drag/select/edit controls in view mode; a plain tap just ticks."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()                     # view mode
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    row = sac.locator("li").filter(has=page.get_by_role("checkbox", name="corde"))
    row.get_by_role("checkbox", name="corde").click()                      # a plain tap
    expect(page.get_by_role("checkbox", name="corde")).to_be_checked()     # just ticks
    assert row.get_by_role("button", name="Renommer").count() == 0         # and reveals nothing
    row.get_by_role("checkbox", name="corde").click()                      # untick, clean row again
    row.get_by_role("checkbox", name="corde").click(delay=700)             # now hold it
    assert row.get_by_label("Réordonner").is_visible()                     # drag
    assert row.get_by_role("checkbox", name="Sélectionner").is_visible()   # select
    assert row.get_by_role("button", name="Renommer").is_visible()         # edit
    assert row.get_by_role("button", name="Supprimer").is_visible()
    expect(page.get_by_role("checkbox", name="corde")).not_to_be_checked() # the hold did not tick
    print("  PASS: longpress item reveals controls")

Revealed this way the controls float free of edit mode’s chrome, with nothing around them to hint how to back out. So they take the dismissal every floating thing here takes: a press anywhere else puts the row back to plain, the same instinct as tapping outside an open add field.

@testcase
def test_tap_elsewhere_hides_item_controls(page):
    """A press away from the revealed thing hides its controls again."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    row = sac.locator("li").filter(has=page.get_by_role("checkbox", name="corde"))
    row.get_by_role("checkbox", name="corde").click(delay=700)             # reveal
    assert row.get_by_role("button", name="Renommer").is_visible()
    page.get_by_placeholder("Ajouter une chose").click()                    # a press outside
    assert page.get_by_role("button", name="Renommer").count() == 0        # controls gone
    print("  PASS: tap elsewhere hides item controls")

Reordering is a list act, not a single-thing one — you slide a thing among its neighbours — so revealing a thing’s grip turns its whole card into a sortable list, the other rows becoming drop targets while only the held thing carries the grip. Dragged, it moves within its card exactly as it would in edit mode.

@testcase
def test_longpress_grip_reorders(page):
    """The grip a long press reveals reorders the thing within its card."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("gants", "bonnet", "casque"):
        add_thing(page, thing, "sac")
    page.get_by_role("button", name="Terminé").click()
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    before = [sac.locator("li span.label").nth(i).inner_text() for i in range(3)]
    moved = before[-1]                                            # whichever thing sits last
    row = sac.locator("li").filter(has=page.get_by_role("checkbox", name=moved))
    row.get_by_role("checkbox", name=moved).click(delay=700)      # reveal its grip
    row.get_by_label("Réordonner").drag_to(sac.locator("li").nth(0))   # drag it to the top
    after = [sac.locator("li span.label").nth(i).inner_text() for i in range(3)]
    assert after == [moved] + [x for x in before if x != moved], (before, after)
    print("  PASS: longpress grip reorders")

A drag needs every row present to drop between, so when the checked things are hidden the grip steps aside — the reveal still offers select and edit, only not a sort into slots that are not on screen. The hidden things stay hidden, too: revealing one thing must not spring the packed ones back to fill the list. It is the same reason edit mode and hiding do not mix.

@testcase
def test_reveal_keeps_checked_hidden(page):
    """Revealing a thing while packed ones are hidden keeps them hidden and offers no grip."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    for thing in ("tente", "corde"):
        add_thing(page, thing, "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("checkbox", name="tente").click()                     # pack one
    page.get_by_role("button", name="Masquer les choses cochées").click()  # hide it
    assert page.get_by_text("tente").count() == 0                          # gone from view
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    row = sac.locator("li").filter(has=page.get_by_role("checkbox", name="corde"))
    row.get_by_role("checkbox", name="corde").click(delay=700)             # reveal corde
    assert row.get_by_role("button", name="Renommer").is_visible()         # controls are up
    assert page.get_by_text("tente").count() == 0                          # yet the packed one stays hidden
    assert row.get_by_label("Réordonner").count() == 0                     # and no grip to sort a gapped list
    print("  PASS: reveal keeps checked hidden")

The revealed select box is the same batch handle edit mode gives — a whole handful can be tagged straight from view mode, no trip through Modifier at all: pick them, then Étiqueter names the tag they all take.

@testcase
def test_longpress_select_tags_in_view_mode(page):
    """The revealed select box reaches the batch tagging without entering edit mode."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_tag(page, "valise")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()                     # view mode
    sac = page.locator("section").filter(has=page.get_by_role("heading", name="sac"))
    row = sac.locator("li").filter(has=page.get_by_role("checkbox", name="corde"))
    row.get_by_role("checkbox", name="corde").click(delay=700)             # reveal
    row.get_by_role("checkbox", name="Sélectionner").check()               # pick it
    page.get_by_role("button", name="Étiqueter").click()
    page.get_by_role("dialog", name="Étiqueter").get_by_role("button", name="valise").click()
    assert page.get_by_role("button", name="Modifier", exact=True).is_visible()        # never left view mode
    valise = page.locator("section").filter(has=page.get_by_role("heading", name="valise"))
    assert valise.get_by_text("corde").is_visible()                        # carries valise now
    assert sac.get_by_text("corde").is_visible()                           # and still sac
    print("  PASS: longpress select tags in view mode")

activate records which placement is revealed. A document-level pointerdown watches for the dismissing press: while a thing is revealed in view mode, a press that lands outside its row puts it back to plain. It is held to view mode, since edit mode keeps every row’s controls out at all times and has nothing to dismiss.

function activate(sk){ activeItem = sk; paint(); }

document.addEventListener('pointerdown', e => {   // a press away from the revealed row puts it back to plain
    if(activeItem && !editMode && !e.target.closest('li.active')){ activeItem = null; paint(); }
});

Folding shares a couple of builders. foldTitle makes a card’s own title the fold control — it fills the title line, so tapping anywhere along it (not just the text) collapses the card (a caret shows which way), flipping its key in folded; there is no separate button. The same title is where the long press lands: handed a hold action it wires the press gesture onto itself, and a hold that has fired is kept from also toggling the fold — the tap and the hold read the same title, but only one acts. It takes a trailing slot rendered just after the name, where the card sets its tally. foldAll is the toolbar’s bulk version — it opens everything if anything is folded, otherwise folds every card at once.

A card is done when it holds things and every one is ticked — cardComplete, the same test that greens a finished card. onArrival is what settles a page as you reach it: it folds the done cards away, and when fewer than fifteen things are left over (pageRemaining) it records them in floatSet for itemRows to lift. It fires once per page — remembering the last one it ran for — and holds off in edit mode, where a fold or a reshuffle would only fight the curating. view runs it as it assembles the page.

function toggleFold(k){ folded.has(k) ? folded.delete(k) : folded.add(k); paint(); }

function foldAll(){
    if(folded.size) folded.clear();
    else Object.keys(tags.toJSON()).forEach(id => folded.add('tag:' + id));
    paint();
}

function cardComplete(s){ return s.items.length > 0 && s.items.every(it => it.done); }

function pageRemaining(m){
    // the still-unticked things on the page arrived at — the trip's, or the whole catalog's
    const o = focusId && m.trips.find(x => x.id === focusId);
    const its = o ? [...o.tags.flatMap(s => s.items), ...o.items] : [...m.catalog.flatMap(s => s.items), ...m.untagged];
    return new Set(its.filter(it => !it.done).map(it => it.id));
}

function onArrival(m){
    const FINAL_STRETCH = 15;
    if(focusId === pageShown) return;   // still on the same page — nothing arrived at
    pageShown = focusId;
    if(editMode){ floatSet = null; return; }   // curating, not packing — leave the view as it is
    for(const s of m.catalog) if(cardComplete(s)) folded.add('tag:' + s.id);   // done cards fold away
    const remaining = pageRemaining(m);
    floatSet = remaining.size < FINAL_STRETCH ? remaining : null;
}

function foldTitle(k, name, trailing, onLong){
    const open = !folded.has(k);
    return html`<span class="fold-title" role="button" tabindex="0"
      aria-expanded=${open ? 'true' : 'false'}
      onpointerdown=${onLong ? () => longPressStart(onLong) : null}
      onpointerup=${onLong ? longPressCancel : null}
      onpointercancel=${onLong ? longPressCancel : null}
      onclick=${() => { if(longPressed()) return; toggleFold(k); }}
      ><span class="caret" aria-hidden="true">${open ? '▾' : '▸'}</span>${name}${trailing || ''}</span>`;
}

Two adornments ride in a card’s heading and speak to its ticks. countBadge is a card’s progress — its ticked things over its total — shown only when it holds anything. uncheckButton is a scope’s reset — it clears the ticks of the things handed to it — shown only while at least one of them is ticked, so it appears exactly when there is something to clear. Like the whole-list reset, it announces the clear with an Annuler to take it back.

function countBadge(list){
    if(!list.length) return '';
    const done = list.filter(it => it.done).length;
    return html`<span class="count">${done}/${list.length}</span>`;
}

function uncheckButton(label, list, msg){
    if(!list.some(it => it.done)) return '';
    return html`<button class="row-btn reset" aria-label=${label}
      onclick=${() => { uncheck(list.map(it => it.id), msg); notify(msg); }}>↺</button>`;
}

A tag card is the workhorse: it appears in the catalog and again inside a focused trip. In the catalog, in edit mode, the whole card is a reorder-item and its heading opens with a drag grip () — drag it to move the card up or down; a card gathered inside a trip is not reorderable, so it takes no grip and no index. Then the heading holds the fold title (the name, tapped to fold) — or, being edited, the rename field — with its progress tally just to the right of the name; out at the right edge sit, in edit mode, its rename/remove pair, a + that opens the card’s own add field, an A→Z that sorts its things by name (once it holds more than one), and last a reset for its ticks. Below, unless folded, come that inline add field when it is open — in either mode, since a long press on the title opens it while packing too — and its things. A trip card is only ever shown in the focused view, so it is plainer — the same layout: its name (renamable) with its tally beside it on the left, and its edit pair and a reset for the whole trip at the right; the cards of the tags it gathers, and, under a Ses propres choses heading, the things carrying the trip’s own name; no fold of its own. untaggedCard is the catalog’s own Sans étiquette group — the things carrying nothing at all, which is where the quick list lives. It carries the same +, and it shows in edit mode even when empty, so there is always somewhere to put the first loose thing.

function tagCard(s, srcTrip, idx){
    const k = 'tag:' + s.id;
    const reorderable = editMode && !srcTrip;   // catalog cards drag; trip-gathered ones do not
    const complete = !editMode && cardComplete(s);
    return html`
      <section class=${(reorderable ? 'reorder-item' : '') + (complete ? ' complete' : '')} data-idx=${reorderable ? idx : null}>
        <h3>${reorderable ? html`<span class="reorder-grip" aria-label="Réordonner">⠿</span>` : ''}${editing === k ? renameField(renameTag, s.id)
            : html`${foldTitle(k, s.name, countBadge(s.items), () => openAdd(s.id))}${editMode ? html`${editButtons(() => startEdit(k, s.name),
                                                     () => removeTagHere(s.id, s.name, srcTrip))}
                     <button class="row-btn" aria-label="Nouvelle chose" onclick=${() => openAdd(s.id)}>+</button>
                     ${s.items.length > 1 ? html`<button class="row-btn" aria-label="Trier de A à Z" onclick=${() => sortTagAlpha(s.id)}>A↓</button>` : ''}` : ''}`}${uncheckButton("Décocher l'étiquette", s.items, 'Étiquette décochée')}</h3>
        ${folded.has(k) ? '' : html`
          ${adding === s.id ? addField(s.id, 'Nouvelle chose', 'Créer la chose') : ''}
          ${itemRows(s.items, s.id)}`}</section>`;
}

function tripCard(o){
    const k = 'tag:' + o.id;
    const its = [...o.tags.flatMap(x => x.items), ...o.items];
    const complete = !editMode && its.length > 0 && its.every(it => it.done);
    return html`
      <article class=${'trip' + (complete ? ' complete' : '')}>
        <h2>${editing === k ? renameField(renameTag, o.id)
            : html`<span class="grow">${o.name}${countBadge(its)}</span>${editMode ? editButtons(() => startEdit(k, o.name),
                                                    () => removeTagHere(o.id, o.name)) : ''}`}${uncheckButton('Décocher la sortie', its, 'Sortie décochée')}</h2>
        ${o.tags.map(s => tagCard(s, o.id))}
        ${o.items.length ? html`<h3>Ses propres choses</h3>${itemRows(o.items, o.id)}` : ''}</article>`;
}

function untaggedCard(untagged){
    if(!untagged.length && !editMode) return '';   // nothing loose, nothing to add — hide it
    return html`
      <section>
        <h3><span class="grow">Sans étiquette${countBadge(untagged)}</span>${editMode ? html`
          <button class="row-btn" aria-label="Nouvelle chose" onclick=${() => openAdd(':untagged')}>+</button>` : ''}${uncheckButton('Décocher', untagged, 'Décoché')}</h3>
        ${editMode && adding === ':untagged' ? addField(':untagged', 'Nouvelle chose', 'Créer la chose') : ''}
        ${itemRows(untagged, '')}</section>`;
}

Planning composes a trip from what is already curated — whole tags, and individual things too. Both are ordinary writes to the two membership maps, so the planner holds no pending sets of its own to reconcile at the end: plan carries only what the screen needs to draw itself — the trip being planned, the text in its name field, and the filter over the things. Opening it from the catalog starts with no trip yet; opening it from a trip’s own view (modifyTrip) starts on that one.

Where the trip is is also where Back should land. Reached from the catalog the planner’s history entry becomes the trip it revealed, so one press leaves planning and a second leaves the trip; reached from a trip that entry is already beneath, so closing simply falls back onto it.

function openPlanner(tripId){
    plan = { id: tripId || '', name: tripId ? (tags.get(tripId)?.name || '') : '',
             filter: '', fromFocus: !!tripId };
    openScreen(() => {
        const { id, fromFocus } = plan;
        plan = null;
        if(id && !fromFocus){                                  // planning is the prelude to packing
            focusId = id;
            openScreen(() => { focusId = null; paint(); });    // so the trip gets the entry the planner had
        }
        paint();
    });
    paint();
}

function modifyTrip(id){ openPlanner(id); }

Nothing can be gathered before there is something to gather it, so the first tap on any chip is also what brings the trip into being — planTrip makes it from the name on screen, or sends you to the name field if there is none, because a trip you cannot name is a trip you cannot come back to. A name already taken by an ordinary tag is refused rather than quietly promoted: ski the topic and ski the trip would be one slug, and the trip would end up gathering itself.

function planTrip(){
    if(plan.id) return plan.id;
    const name = (plan.name || '').trim();
    if(!name){ document.querySelector('.plan-name')?.focus(); return ''; }
    const taken = tags.get(slug(name));
    if(taken && !taken.trip){ notify('« ' + taken.name + ' » est déjà une étiquette'); return ''; }
    plan.id = taken ? slug(name) : addTag(name, true);
    return plan.id;
}

Each toggle is written as it is made, one commit apiece, so the undo walks the plan back decision by decision rather than dropping it whole. Gathering a tag writes gathers; taking a thing along is toggleTag with the trip’s own name, the very gesture the row chips use. A thing typed here is created carrying the trip already.

function togglePlanTag(tagId){
    const id = planTrip(); if(!id) return;
    const k = id + ':' + tagId;
    if(gathers.get(k)) gathers.delete(k); else gathers.set(k, 1);
    commit('sortie « ' + tags.get(id).name + ' » composée');
    paint();
}

function togglePlanItem(itemId){
    const id = planTrip(); if(!id) return;
    toggleTag(itemId, id);
    paint();
}

function planAddItem(){
    const id = planTrip(); if(!id) return;
    const box = document.querySelector('.plan-add-input');
    const val = box.value;
    box.value = '';
    addItem(val, id);
    box.focus();
}

Two derivations feed the screen. planTrips is the name field’s completion — the trips whose name the text is slugging toward — and it goes quiet once a trip is settled, since there is nothing left to pick. planCovered is the things already coming along inside a gathered tag, which the things list drops.

function planTrips(){
    const q = slug(plan.name);
    if(!q || plan.id) return [];
    return Object.entries(tags.toJSON()).filter(([, v]) => v.trip && slug(v.name).includes(q));
}

function planCovered(){
    const mine = new Set(Object.keys(gathers.toJSON()).filter(k => before(k) === plan.id).map(after));
    const covered = new Set();
    for(const k of Object.keys(tagged.toJSON())) if(mine.has(after(k))) covered.add(before(k));
    return covered;
}

The panel is then one column read top to bottom: who, which tags, which extra things. The name field binds no value back, for the reason the filter does not — the caret would be dragged to the end on every keystroke — so picking a completion writes into the node directly. The things list carries the catalog’s own filter, and ends with the add field.

function pickTrip(id, name){
    const box = document.querySelector('.plan-name');
    if(box) box.value = name;
    plan.id = id; plan.name = name; paint();
}

function plannerPanel(){
    const trip = plan.id, hits = planTrips(), covered = trip ? planCovered() : new Set();
    const q = plan.filter.trim();
    return html`<div class="sheet-back" onclick=${backdropClose(goBack)}><div class="sheet planner" role="dialog" aria-label="Planifier une sortie">
      <h2 class="sheet-title">Planifier une sortie</h2>
      <input class="sheet-name plan-name" placeholder="Nom de la sortie" aria-label="Nom de la sortie"
             oninput=${e => { plan.name = e.target.value; paint(); }}>
      ${hits.length ? html`<div class="planner-trips">${hits.map(([id, v]) => html`
        <button class="pick-target" onclick=${() => pickTrip(id, v.name)}>${v.name}</button>`)}</div>` : ''}
      <h3 class="sheet-group">Quelles étiquettes ?</h3>
      <div class="planner-tags">${Object.entries(tags.toJSON()).filter(([, v]) => !v.trip).map(([id, v]) => html`
        <button class="tagchip" aria-pressed=${trip && gathers.get(trip + ':' + id) ? 'true' : 'false'}
                onclick=${() => togglePlanTag(id)}>${v.name}</button>`)}</div>
      <h3 class="sheet-group">Quelles choses en plus ?</h3>
      <input type="search" class="sheet-filter" placeholder="Filtrer" aria-label="Filtrer les choses"
             oninput=${e => { plan.filter = e.target.value; paint(); }}>
      <div class="planner-things">${Object.entries(items.toJSON())
        .filter(([id, v]) => !covered.has(id) && matchesFilter(v.label, q))
        .map(([id, v]) => html`
          <button class="tagchip" aria-pressed=${trip && tagged.get(id + ':' + trip) ? 'true' : 'false'}
                  onclick=${() => togglePlanItem(id)}>${v.label}</button>`)}</div>
      <div class="add">
        <input class="plan-add-input" placeholder="Ajouter une chose" aria-label="Ajouter une chose"
               onkeydown=${e => { if(e.key === 'Enter') planAddItem(); }}>
        <button aria-label="Ajouter la chose" onclick=${planAddItem}>+</button>
      </div>
    </div></div>`;
}

The list has two faces. The catalog is for curation; above it a compact chip per trip shows that trip’s tick progress, and tapping one focuses it — the packing view: only its cards and things, the add field (a thing typed there joins this trip), a filter to narrow them, a back to the list, a control to re-plan the trip (modifyTrip, reopening the planner on it), a fold-all for its cards, a toggle to hide the ticked things, an undo/redo since ticking is the work there, and the Modifier/–/Terminé toggle so the same curation you have in the catalog is at hand here too. doUndo=/=doRedo step Loro’s undo manager, whose own changes repaint through the same subscribe.

function doUndo(){ if(undo.canUndo()) undo.undo(); }
function doRedo(){ if(undo.canRedo()) undo.redo(); }

function tripChip(o){
    const its = [...o.tags.flatMap(x => x.items), ...o.items], done = its.filter(it => it.done).length;
    return html`<button class="chip" onclick=${() => { focusId = o.id; openScreen(() => { focusId = null; paint(); }); paint(); }}>
      ${o.name} <small>${done}/${its.length}</small></button>`;
}

function focusView(m){
    const o = m.trips.find(x => x.id === focusId);
    const q = filter.trim();
    const match = it => matchesFilter(it.label, q);
    const fo = q ? { ...o, tags: o.tags.map(s => ({ ...s, items: s.items.filter(match) })).filter(s => s.items.length),
                     items: o.items.filter(match) } : o;
    return html`
      <div class="toolbar">
        <button class="edit-toggle" aria-label="Retour" onclick=${() => goBack()}>← Retour</button>
        <span class="toolbar-actions">
          <button class="row-btn" aria-label="Modifier la sortie" onclick=${() => modifyTrip(focusId)}>✎</button>
          <button class="row-btn" aria-label=${folded.size ? 'Tout déplier' : 'Tout plier'}
                  onclick=${foldAll}>${folded.size ? '⊞' : '⊟'}</button>
          <button class="row-btn" aria-label=${hideChecked ? 'Afficher les choses cochées' : 'Masquer les choses cochées'}
                  onclick=${toggleHideChecked}>${hideChecked ? '☐' : '☑'}</button>
          <button class="row-btn" aria-label="Défaire" ?disabled=${!undo.canUndo()} onclick=${doUndo}>↶</button>
          <button class="row-btn" aria-label="Refaire" ?disabled=${!undo.canRedo()} onclick=${doRedo}>↷</button>
          <button class="edit-toggle" onclick=${toggleEdit}>${editMode ? 'Terminé' : 'Modifier'}</button>
        </span>
      </div>
      ${selected.size ? selectionBar() : ''}
      ${mainAddField()}
      ${filterBar()}
      ${tripCard(fo)}
      ${plan ? plannerPanel() : ''}
      ${picker ? pickerPanel() : ''}
      ${toast ? toastView() : ''}`;
}

An intrusive edit — a delete, a taking-out, a batch tagging, or clearing the whole list — happens at once, so it needs a loud enough after-the-fact signal that a stray tap cannot pass unnoticed. notify raises a toast that slides up from the foot of the screen, names what just happened, and carries an Annuler that undoes it; it clears itself after a few seconds, or sooner if another notification takes its place. This is the safety net in place of a confirm-before dialog: acting is one tap, and the rare unwanted one is one more.

let toastTimer;
function notify(text){
    toast = { text };
    paint();
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => { toast = null; paint(); }, 5000);
}
function dismissToast(){ clearTimeout(toastTimer); toast = null; paint(); }
function undoToast(){ dismissToast(); doUndo(); }

function toastView(){
    return html`<div class="toast" role="status">
      <span class="toast-msg">${toast.text}</span>
      <button class="toast-undo" onclick=${undoToast}>Annuler</button>
    </div>`;
}

The tree assembles the catalog view — when focusId is set it hands off to focusView instead. The toolbar keeps the sync state on the left and, on the right, a fold-all, a toggle to hide the ticked things, the plan-a-trip control (openPlanner), an undo and a redo (disabled with nothing to walk to), the Modifier/–/Terminé toggle, and — set apart at the far right — an uncheck-all, since wiping every tick is the one action you want out of easy reach of the rest. All are in both modes, since a tick, resetting, planning, folding and undo all matter while packing too. Below: the trip chips; then the add field and the filter, and under them the catalog of every tag, the Sans étiquette group, and — in edit mode — a + Étiquette at the foot. The catalog and the untagged narrow to the things the filter’s words match, empty cards dropping out while it is set. The Modifier/–/Terminé toggle simply flips editMode; leaveEdit also clears anything half-started — a pending batch selection, an open inline add field, the tag picker — so stepping out of edit leaves nothing dangling.

function leaveEdit(){ editMode = false; editing = null; selected.clear(); adding = null; picker = null; activeItem = null; paint(); }

function toggleEdit(){ if(editMode) leaveEdit(); else { editMode = true; activeItem = null; paint(); } }

function view(m){
    onArrival(m);   // on arriving at a page, settle it: fold the done cards, float up the last stragglers
    if(focusId && m.trips.some(o => o.id === focusId)) return focusView(m);
    focusId = null;   // no trip focused, or the focused one is gone — show the catalog
    const empty = !m.trips.length && !m.catalog.length && !m.untagged.length;
    const q = filter.trim();
    const match = it => matchesFilter(it.label, q);
    const catalog = q ? m.catalog.map(s => ({ ...s, items: s.items.filter(match) })).filter(s => s.items.length) : m.catalog;
    const untagged = q ? m.untagged.filter(match) : m.untagged;
    const syncLabel = { off: 'Local', connecting: 'Connexion…', online: 'Synchronisé',
                        offline: 'Hors ligne' + (syncCode ? ' ' + syncCode : '') }[syncState];
    return html`
      <div class="toolbar">
        <button class=${'sync' + (onProd() ? '' : ' offprod')} data-sync=${syncState} aria-label="État de synchronisation"
                onclick=${() => { syncUrlShown = !syncUrlShown; paint(); }}>${syncLabel}</button>
        <span class="toolbar-actions">
          <button class="row-btn" aria-label=${folded.size ? 'Tout déplier' : 'Tout plier'}
                  onclick=${foldAll}>${folded.size ? '⊞' : '⊟'}</button>
          <button class="row-btn" aria-label=${hideChecked ? 'Afficher les choses cochées' : 'Masquer les choses cochées'}
                  onclick=${toggleHideChecked}>${hideChecked ? '☐' : '☑'}</button>
          <button class="row-btn" aria-label="Défaire" ?disabled=${!undo.canUndo()}
                  onclick=${doUndo}>↶</button>
          <button class="row-btn" aria-label="Refaire" ?disabled=${!undo.canRedo()}
                  onclick=${doRedo}>↷</button>
          <button class="row-btn" aria-label="Historique" onclick=${openHistory}>🕘</button>
          <button class="row-btn" aria-label="Planifier une sortie" onclick=${() => openPlanner()}>🧳</button>
          <button class="edit-toggle" onclick=${toggleEdit}>${editMode ? 'Terminé' : 'Modifier'}</button>
          <button class="row-btn" aria-label="Tout décocher" onclick=${uncheckAll}>↺</button>
        </span>
      </div>
      ${syncUrlShown ? html`<div class="sync-url">${syncUrl || 'Local — pas de synchronisation'}</div>` : ''}
      ${selected.size ? selectionBar() : ''}
      ${m.trips.length ? html`<div class="trips">${m.trips.map(tripChip)}</div>` : ''}
      ${mainAddField()}
      ${empty && !editMode ? html`<p class="empty">Rien à prendre pour l'instant.</p>` : html`
        ${empty ? '' : filterBar()}
        <div class=${editMode ? 'reorder-list' : ''} data-reorder=${editMode ? 'tags' : null}>${catalog.map((s, i) => tagCard(s, '', i))}</div>
        ${untaggedCard(untagged)}
        ${editMode ? addTagActuator() : ''}`}
      ${plan ? plannerPanel() : ''}
      ${picker ? pickerPanel() : ''}
      ${viewingFrontier ? viewingBar() : ''}
      ${historyOpen ? historyView() : ''}
      ${toast ? toastView() : ''}`;
}

body{font-family:system-ui,sans-serif;color:#1b1d2e;margin:0;background:#fff}
body[data-edit]{background:#fff5e0}
body[data-edit] .toolbar{background:#fff5e0}
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 16px;max-width:480px;margin:0 auto;position:sticky;top:0;background:#fff;z-index:10}
.toolbar-actions{display:flex;align-items:center;gap:6px}
.sync{display:flex;align-items:center;gap:6px;font-size:.8rem;color:#8a8ea5;border:0;background:none;padding:0;font-family:inherit;cursor:pointer}
.sync-url{max-width:480px;margin:0 auto 4px;padding:0 16px;font-size:.72rem;color:#8a8ea5;word-break:break-all}
.sync.offprod{background:#ffe0a3;color:#7a5b00;padding:2px 12px;border-radius:999px}
.sync::before{content:"";width:9px;height:9px;border-radius:50%;background:#c4c6d4;flex:none}
.sync[data-sync=connecting]::before{background:#e0a92e}
.sync[data-sync=online]::before{background:#2e9e5b}
.sync[data-sync=offline]::before{background:#d64545}
.edit-toggle{padding:8px 14px;border:1px solid #ccc;border-radius:8px;background:#fff;color:#1b1d2e;font:inherit;cursor:pointer}
.trips{display:flex;flex-wrap:wrap;gap:8px;max-width:480px;margin:10px auto;padding:0 16px}
.chip{padding:8px 12px;border:1px solid #d7d9e6;border-radius:999px;background:#f7f8fc;color:#1b1d2e;font:inherit;cursor:pointer}
.chip small{color:#8a8ea5}
section{max-width:480px;margin:0 auto}
h3{display:flex;align-items:center;gap:6px;padding:0 16px;margin:16px 0 6px;font-size:.85rem;color:#8a8ea5;text-transform:uppercase;letter-spacing:.04em}
.reset{margin-left:auto}
.items{list-style:none;padding:0 16px;margin:0}
.items li{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:12px 14px;margin-bottom:6px;background:#f2f3f7;border-radius:8px}
.items li.selected{outline:2px solid #1b1d2e;outline-offset:-2px}
.items li.active{outline:2px solid #c4c6d4;outline-offset:-2px}
.items.solo li.reorder-item{margin-bottom:6px}
.select{flex:none;width:16px;height:16px;margin:0}
.check{flex:none;box-sizing:border-box;width:22px;height:22px;border:2px solid #c4c6d4;border-radius:5px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
.check[aria-checked=true]{border-color:#1b1d2e;background:#1b1d2e;color:#fff}
.check[aria-checked=true]::before{content:"✓";font-size:14px}
.label{flex:1;min-width:0;cursor:default}
.items li.done .label{color:#8a8ea5;text-decoration:line-through}
.row-btn{background:none;border:0;cursor:pointer;color:#8a8ea5;font-size:1rem;line-height:1;padding:4px 6px}
.row-btn:disabled{opacity:.3;cursor:default}
.fold-title{flex:1;min-width:0;text-align:left;cursor:pointer}
.fold-title .caret{color:#8a8ea5;margin-right:6px;font-size:.75rem}
.count{flex:none;margin-left:8px;font-size:.75rem;color:#8a8ea5;font-weight:400;font-variant-numeric:tabular-nums}
.grow{flex:1;min-width:0}
.rename-input{flex:1;padding:10px 12px;border:1px solid #ccc;border-radius:8px;font:inherit}
.empty{text-align:center;color:#8a8ea5;margin-top:40px}
.trip{max-width:480px;margin:22px auto;padding:2px 0 10px;border:1px solid #e2e3ee;border-radius:12px;background:#fafaff}
.trip > h2{display:flex;align-items:center;gap:6px;padding:12px 16px 0;margin:0;font-size:1.05rem;color:#1b1d2e;font-weight:650}
.trip section{max-width:none;margin:0}
section.complete{background:#e6f7ec;border-radius:12px}
.trip.complete{background:#e6f7ec;border-color:#a9dcbb}

The bulk gesture the selection boxes promised comes down to one function. tagSelected gives every picked thing the chosen tag, or takes it away from every one of them, in a single commit and a single undo step, then spends the selection. The selection holds itemId|tagId keys — a thing picked under one of its tags — but only the thing half matters here, since the tag being applied is the one just chosen.

function tagSelected(tagId, mode){
    [...selected].forEach(k => {
        const itemId = k.split('|')[0];
        if(mode === 'untag') tagged.delete(itemId + ':' + tagId);
        else tagged.set(itemId + ':' + tagId, 1);
    });
    selected.clear();
    commit((mode === 'untag' ? 'détaché de « ' : 'étiqueté « ') + (tags.get(tagId)?.name || tagId) + ' »');
    paint();
}

The two batch controls live in a bar that appears the moment something is selected. Étiqueter and Détacher open one target sheet — a plain list of every tag — and the tag tapped is the one applied. openPicker remembers which verb is pending (as a screen, so Back closes the sheet), applyPick runs it then steps back out; selectionBar also carries the count and a way to drop the selection without acting.

function openPicker(mode){ picker = mode; openScreen(() => { picker = null; paint(); }); paint(); }
function applyPick(tagId){
    const mode = picker, name = tags.get(tagId).name;
    tagSelected(tagId, mode);
    notify((mode === 'untag' ? 'Détaché de « ' : 'Étiqueté « ') + name + ' »');
    goBack();
}

function selectionBar(){
    const n = selected.size;
    return html`<div class="selbar">
      <span class="selcount">${n} sélectionné${n > 1 ? 's' : ''}</span>
      <button onclick=${() => openPicker('tag')}>Étiqueter</button>
      <button onclick=${() => openPicker('untag')}>Détacher</button>
      <button class="selclear" aria-label="Tout désélectionner" onclick=${() => { selected.clear(); paint(); }}>✕</button>
    </div>`;
}

function pickerPanel(){
    const verb = picker === 'untag' ? 'Détacher' : 'Étiqueter';
    return html`<div class="sheet-back" onclick=${backdropClose(goBack)}><div class="sheet picker" role="dialog" aria-label=${verb}>
      <p class="sheet-msg">${verb}…</p>
      ${Object.entries(tags.toJSON()).filter(([, v]) => !v.trip).map(([id, v]) => html`
        <button class="pick-target" onclick=${() => applyPick(id)}>${v.name}</button>`)}
      <button class="sheet-quiet" onclick=${() => goBack()}>Annuler</button>
    </div></div>`;
}

The sheets share a look — the exit prompt and the batch tag sheet, the latter a scrolling column of tag buttons. All are sized border-box, so their padding counts inside the width rather than adding to it. The planner’s add row needs one rule more than the shrink-to-fit it already has: pinned to the sheet’s own width, since left to itself it would be the widest line in that stretching column and would set the column’s width instead of taking it. The selection bar is a sticky strip, tinted apart from the list so it reads as a pending action. The toast is a pill at the foot of the screen, centred with translateX(-50%) — kept in the base rule, not only in the slide-in keyframe, because that animation is not forwards, so on finishing the element snaps back to the base rule and would lose its centring otherwise.

.selbar{position:sticky;top:52px;z-index:9;display:flex;align-items:center;gap:8px;
        max-width:480px;margin:8px auto;padding:8px 12px;background:#1b1d2e;color:#fff;border-radius:8px}
.selcount{flex:1;font-size:.9rem}
.selbar button{padding:8px 12px;border:0;border-radius:8px;background:#fff;color:#1b1d2e;font:inherit;cursor:pointer}
.selbar .selclear{background:transparent;color:#fff;padding:4px 8px;font-size:1rem}
.sheet.picker{flex-direction:column;flex-wrap:nowrap;align-items:stretch;max-width:360px;width:100%;max-height:80vh;overflow:auto}
.sheet.picker .sheet-quiet{background:#fff;color:#1b1d2e;border:1px solid #ccc}
.sheet.history{flex-direction:column;flex-wrap:nowrap;align-items:stretch;max-width:360px;width:100%;max-height:80vh;overflow:auto}
.sheet.history .sheet-quiet{background:#fff;color:#1b1d2e;border:1px solid #ccc}
.hist-graph-wrap{position:relative}
.hist-graph{position:absolute;left:0;top:0;pointer-events:none;overflow:visible}
.hist-rows{display:flex;flex-direction:column}
.hist-row{display:flex;justify-content:space-between;align-items:center;gap:12px;width:100%;box-sizing:border-box;text-align:left;padding:8px 4px;min-height:40px;border:0;border-bottom:1px solid #f0f0f4;background:none;font:inherit;color:inherit;cursor:pointer}
.hist-row:active{background:#f2f3f7}
.hist-msg{flex:1;min-width:0;font-size:.9rem;line-height:1.3;overflow-wrap:anywhere}
.hist-time{flex:none;white-space:nowrap;color:#8a8ea5;font-size:.75rem}
.hist-more{margin:6px 0 0;color:#8a8ea5;font-size:.75rem;text-align:center}
.hist-here{background:#fff7e6}
.hist-here .hist-msg{font-weight:650}
/* previewing a past point: the app under the bar is read-only, only overlays stay live */
body[data-viewing] #app{pointer-events:none}
body[data-viewing] .sheet-back,body[data-viewing] .toast,body[data-viewing] .viewing-bar{pointer-events:auto}
.viewing-bar{position:fixed;left:0;right:0;top:0;z-index:9990;display:flex;flex-wrap:wrap;
             align-items:center;justify-content:space-between;gap:8px;
             padding:10px 14px;background:#1b1d2e;color:#fff;box-shadow:0 4px 16px #0004}
.viewing-msg{font-size:.9rem;flex:1;min-width:0;overflow-wrap:anywhere}
.viewing-acts{display:flex;flex-wrap:wrap;gap:8px}
.viewing-bar button{padding:8px 14px;border:0;border-radius:8px;background:#fff;color:#1b1d2e;font:inherit;font-size:.85rem;cursor:pointer}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:9999;
       display:flex;align-items:center;gap:14px;max-width:calc(100vw - 32px);
       padding:12px 12px 12px 18px;border-radius:999px;
       background:#1b1d2e;color:#fff;box-shadow:0 8px 24px #0005;
       animation:toast-in .28s cubic-bezier(.2,1.3,.4,1)}
.toast-msg{font-size:.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.toast-undo{flex:none;padding:8px 16px;border:0;border-radius:999px;background:#f6c453;color:#1b1d2e;font:inherit;font-weight:650;cursor:pointer}
@keyframes toast-in{from{opacity:0;transform:translate(-50%,140%)}to{opacity:1;transform:translate(-50%,0)}}
.sheet-back{position:fixed;inset:0;background:#0006;display:flex;
            align-items:center;justify-content:center;z-index:9998}
.sheet{background:#fff;border-radius:12px;padding:16px;display:flex;flex-wrap:wrap;gap:12px;max-width:320px;box-sizing:border-box}
.sheet-msg{flex-basis:100%;margin:0}
.sheet button{padding:12px 20px;border:0;border-radius:8px;
              background:#1b1d2e;color:#fff;font:inherit;cursor:pointer}
.sheet.planner{flex-direction:column;flex-wrap:nowrap;align-items:stretch;max-width:360px;width:100%;max-height:80vh;overflow:auto}
.sheet-title{margin:0 0 2px;font-size:1.05rem}
.sheet-group{margin:8px 0 2px;font-size:.75rem;color:#8a8ea5;text-transform:uppercase;letter-spacing:.04em}
.sheet-name{padding:10px 12px;border:1px solid #ccc;border-radius:8px;font:inherit}
.sheet-filter{padding:10px 12px;border:1px solid #ccc;border-radius:8px;font:inherit}
.sheet.planner .add{padding:8px 0;box-sizing:border-box;width:100%;max-width:none}
.planner-trips{display:flex;flex-direction:column;gap:4px}
.planner-tags,.planner-things{display:flex;flex-wrap:wrap;gap:6px}
.tagchip{padding:6px 12px;border:1px solid #d7d9e6;border-radius:999px;background:#fff;color:#8a8ea5;font:inherit;font-size:.85rem;cursor:pointer}
.tagchip[aria-pressed=true]{background:#1b1d2e;border-color:#1b1d2e;color:#fff}
.row-tags{flex-basis:100%;display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}

Two phones building the same lists should see each other’s additions without one being passed around. So the list optionally syncs: opened once with a ?sync_url=wss://… it connects to a relay, sends its document, and merges whatever comes back. The relay passes each update between the phones sharing a room; Loro does the merging, so no side has to arbitrate — the same optional-sync shape as Condorcet.

@testcase
def test_sync_between_devices(page):
    """An item added on one device shows up on another through the relay."""
    sync = require_sync_server()
    browser = page.context.browser
    with two_contexts(browser) as (ca, cb):
        a = open_app(ca, f"{BASE_URL}?sync_url={sync}/triggerlist")
        b = open_app(cb, f"{BASE_URL}?sync_url={sync}/triggerlist")
        edit(a)
        add_thing(a, "tente")
        expect(b.get_by_text("tente")).to_be_visible(timeout=8000)
        print("  PASS: sync between devices")

A phone that opens the room later — after the others have closed — should still find what they packed. So the relay does more than pass updates along: it keeps each room’s document and replays it to whoever connects, so history outlives any one device.

@testcase
def test_late_joiner_gets_history(page):
    """A phone joining a room after another added and left still gets the data."""
    sync = require_sync_server()
    browser = page.context.browser
    ca = browser.new_context(viewport=PHONE_VIEWPORT)
    a = open_app(ca, f"{BASE_URL}?sync_url={sync}/histoire")
    edit(a)
    add_thing(a, "réchaud")
    a.wait_for_timeout(500)   # let the server fold in and persist the update
    ca.close()                # everyone leaves the room
    cb = browser.new_context(viewport=PHONE_VIEWPORT)
    b = open_app(cb, f"{BASE_URL}?sync_url={sync}/histoire")
    try:
        expect(b.get_by_text("réchaud")).to_be_visible(timeout=8000)
        print("  PASS: late joiner gets history")
    finally:
        cb.close()

The toolbar says where the list stands — Local with no relay, Connexion…, Synchronisé, Hors ligne — so a glance shows whether edits are leaving the device.

@testcase
def test_sync_status_local(page):
    """With no relay, the toolbar shows the list is local-only."""
    clear_state(page)
    assert page.get_by_text("Local").is_visible()
    print("  PASS: sync status local")

@testcase
def test_sync_status_online(page):
    """Opened against a live relay, the toolbar shows it synchronised."""
    sync = require_sync_server()
    ctx = page.context.browser.new_context(viewport=PHONE_VIEWPORT)
    try:
        p = open_app(ctx, f"{BASE_URL}?sync_url={sync}/statuscheck")
        expect(p.get_by_text("Synchronisé")).to_be_visible(timeout=8000)
        print("  PASS: sync status online")
    finally:
        ctx.close()

When the drop is the gate refusing, the pill appends the http status: a missing cookie (401) reads differently from a wrong room (404) or a server down (50x). Here the probe is stubbed to 401.

@testcase
def test_sync_status_error_code(page):
    """A relay refusing with an HTTP error shows that code in the pill."""
    page.route("http://localhost:9682/refused", lambda route: route.fulfill(status=401))
    clear_state(page)
    page.goto(f"{BASE_URL}?sync_url=ws://localhost:9682/refused")
    page.wait_for_selector("#app > *")
    expect(page.get_by_text("Hors ligne 401")).to_be_visible(timeout=8000)
    page.unroute("http://localhost:9682/refused")
    print("  PASS: sync status error code")

A relay that is down or refusing must not be hammered. Each reconnect waits longer than the last, so the attempts space out instead of firing at a fixed rate. Pointed at a socket that accepts and drops every connection, the gaps between successive attempts grow.

@testcase
def test_reconnect_backs_off(page):
    """After repeated drops, reconnection intervals grow instead of staying flat."""
    port, hits, srv = counting_refuser()
    try:
        page.route(f"http://localhost:{port}/room", lambda route: route.fulfill(status=503))
        clear_state(page)
        page.goto(f"{BASE_URL}?sync_url=ws://localhost:{port}/room")
        page.wait_for_selector("#app > *")
        page.wait_for_timeout(6000)
    finally:
        srv.close()
    assert len(hits) >= 3, f"expected at least three attempts, got {len(hits)}"
    gaps = [b - a for a, b in zip(hits, hits[1:])]
    assert gaps[-1] > gaps[0] * 1.5, f"reconnect not backing off: {gaps}"
    print("  PASS: reconnect backs off")

Added to a phone’s home screen the list should open like an app, not a browser tab, and it should survive a dead network — the whole point of packing before a trip is doing it where the signal is bad. So it is a PWA: a manifest asks for a fullscreen display, points at icons so the browser will actually install it, and a service worker caches what it fetches and serves it back when the network is gone. The manifest is linked from the shell and declares the display it wants.

@testcase
def test_pwa_manifest_is_fullscreen(page):
    """The app links a web manifest that requests a fullscreen display."""
    clear_state(page)
    href = page.get_attribute("link[rel=manifest]", "href")
    assert href, "no manifest linked"
    manifest = page.evaluate("h => fetch(h).then(r => r.json())", href)
    assert manifest["display"] == "fullscreen", manifest
    print("  PASS: pwa manifest is fullscreen")

A fullscreen display alone does not make the app installable: a browser only offers to install a standalone app when the manifest gives it a square icon of at least 192 and 512 pixels. Without them the Add to home screen makes a plain shortcut that opens in a browser tab — which is exactly the “won’t install fullscreen” symptom. So the check makes sure both sizes are declared.

@testcase
def test_pwa_has_install_icons(page):
    """The manifest declares the 192 and 512 icons a browser needs to install."""
    clear_state(page)
    href = page.get_attribute("link[rel=manifest]", "href")
    manifest = page.evaluate("h => fetch(h).then(r => r.json())", href)
    sizes = {i.get("sizes") for i in manifest.get("icons", [])}
    assert "192x192" in sizes and "512x512" in sizes, manifest
    print("  PASS: pwa has install icons")

The worker’s caching is network-first: fresh when there is a network, cached when there is not. Once it controls the page and the shell has been fetched, cutting the network and reloading still brings the app up.

@testcase
def test_pwa_works_offline(page):
    """With the network cut, the service worker still serves the app from cache."""
    ctx = page.context.browser.new_context(viewport=PHONE_VIEWPORT)
    try:
        p = open_app(ctx, BASE_URL)
        p.wait_for_function("() => navigator.serviceWorker.controller", timeout=8000)
        p.reload()
        p.wait_for_selector("#app > *")
        ctx.set_offline(True)
        p.reload()
        p.wait_for_selector("#app > *", timeout=8000)
        assert p.get_by_role("button", name="Modifier", exact=True).is_visible()
    finally:
        ctx.close()
    print("  PASS: pwa works offline")

The socket drives that pill. connect opens it and moves syncState: Connexion…, then Synchronisé once it is open — when it sends its snapshot and begins importing the peer’s changes; on a close, Hors ligne, after which it retries so a dropped link heals itself — each wait twice the last, from one second up to a thirty-second ceiling, reset the moment a connection opens, so the attempts space out instead of pinning a relay that stays down. A close carries no http status — the WebSocket hides the failed handshake’s code — so probeStatus reads it from the same-origin http endpoint instead. A cross-origin relay (as in production, where the app and the relay sit on different hosts) cannot be read this way, and the pill stays a plain Hors ligne there.

function setSync(state, code = ''){ syncState = state; syncCode = code; paint(); }

async function probeStatus(){
    try {
        const res = await fetch(syncUrl.replace(/^ws/, 'http'), { credentials: 'include' });
        return res.status >= 400 ? String(res.status) : '';
    } catch { return ''; }
}

function connect(){
    const RECONNECT_MIN = 1000, RECONNECT_MAX = 30000;
    retryDelay ??= RECONNECT_MIN;
    setSync('connecting');
    ws = new WebSocket(syncUrl);
    ws.binaryType = 'arraybuffer';
    ws.onopen = () => { setSync('online'); retryDelay = RECONNECT_MIN; ws.send(doc.export({ mode: 'snapshot' })); };
    ws.onmessage = (e) => doc.import(new Uint8Array(e.data));
    ws.onclose = () => {
        setSync('offline');
        probeStatus().then((code) => code && setSync('offline', code));
        setTimeout(connect, retryDelay);
        retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX);
    };
}

The url is opt-in and remembered: resolveSyncUrl reads it, and passed once it goes to localStorage and is stripped from the address bar, so a link shared without it still syncs. It runs before the document loads, because the room decides which cache (docKey) to read. Then, with no url the list stays local; with one, startSync subscribes — broadcasting only its own local edits, the by == ’local’= guard keeping a peer’s imported change from bouncing back — and opens the socket.

function resolveSyncUrl(){
    const params = new URLSearchParams(location.search);
    let url = params.get('sync_url');
    if(url){
        localStorage.setItem('triggerlist.sync_url', url);
        params.delete('sync_url');
        const q = params.toString();
        history.replaceState(null, '', location.pathname + (q ? '?' + q : ''));
    } else {
        url = localStorage.getItem('triggerlist.sync_url');
    }
    syncUrl = url || undefined;
}

function onProd(){ return !!syncUrl && syncUrl.endsWith('lorosync/triggerlist'); }

function startSync(){
    if(!syncUrl) return;
    doc.subscribe((batch) => {
        if(batch.by === 'local' && ws && ws.readyState === WebSocket.OPEN)
            ws.send(doc.export({ mode: 'update' }));
    });
    connect();
}

The url is worth being able to check — above all to tell a debug room from the live one — but it should not clutter the packing view. So the toolbar’s sync-status pill doubles as a reveal: tap it and a small line under the toolbar shows the current url (or that the list is local); tap again to hide it.

@testcase
def test_sync_url_visible_on_tap(page):
    """Tapping the sync status reveals the room's sync url."""
    clear_state(page)
    page.goto(BASE_URL + "?sync_url=wss://example.test/lorosync/room")
    page.wait_for_selector("#app > *")
    page.get_by_role("button", name="État de synchronisation").click()
    assert page.get_by_text("wss://example.test/lorosync/room").is_visible()
    print("  PASS: sync url visible on tap")

The cached copy is kept per room, so opening a different room — a debug relay, say — never carries the room you left into the one you join, and so never pushes it there on connect.

@testcase
def test_switching_sync_room_does_not_carry_state(page):
    """Switching to another sync room loads that room's own cached doc, not the last room's."""
    clear_state(page)
    page.goto(BASE_URL + "?sync_url=ws://localhost:1/dev")
    page.wait_for_selector("#app > *")
    edit(page)
    add_thing(page, "secret dev thing")
    assert page.get_by_text("secret dev thing").is_visible()
    page.wait_for_timeout(300)   # let the snapshot persist under the dev-room key
    page.goto(BASE_URL + "?sync_url=ws://localhost:1/prod")
    page.wait_for_selector("#app > *")
    assert page.get_by_text("secret dev thing").count() == 0   # the prod room's cache is its own
    print("  PASS: switching sync room does not carry state")

One more guard against mistaking a throwaway room for the live one: the live room is the one whose url ends with lorosync/triggerlist; every other room, and the no-sync Local list, is off the live list. So the connection pill is tinted amber whenever you are not on the live room — a standing reminder that edits here are not reaching prod — and stays plain only on the live room itself.

@testcase
def test_off_prod_sync_pill_tinted(page):
    """The connection pill is tinted off the live room (a dev room, or Local) and plain on it."""
    clear_state(page)
    def pill_bg():
        return page.get_by_role("button", name="État de synchronisation").evaluate(
            "el => getComputedStyle(el).backgroundColor")
    local_bg = pill_bg()                             # Local: not the live room -> tinted
    page.goto(BASE_URL + "?sync_url=ws://localhost:1/lorosync/triggerlist-dev")
    page.wait_for_selector("#app > *")
    dev_bg = pill_bg()                               # a dev room -> tinted
    page.goto(BASE_URL + "?sync_url=ws://localhost:1/lorosync/triggerlist")
    page.wait_for_selector("#app > *")
    prod_bg = pill_bg()                              # the live room -> plain
    assert local_bg == dev_bg, (local_bg, dev_bg)    # off-prod shares one warning tint
    assert prod_bg != dev_bg, (prod_bg, dev_bg)      # the live room stands apart
    assert [int(x) for x in re.findall(r"\d+", dev_bg)][:3] != [0, 0, 0], dev_bg   # a real colour
    print("  PASS: off prod sync pill tinted")

Undo walks back a change at a time, but blind — you cannot see what each step was. So a history screen (the 🕘 in the toolbar) lists the changes newest first, each named by what it did: « crème solaire » coché, réordonné, étiquette « ski » ajoutée. A name is the whole of what sets one row apart from the next, so a row shows it in full — wrapping onto as many lines as it needs rather than clipping it behind an ellipsis. In practice each mutation commits with that description as its message, which does double duty: Loro coalesces adjacent local commits into one change unless each carries a distinct message, so the messages both name a step and keep the steps apart — an undo or redo, carrying none, reads simply as modification.

Each entry is also a place to stand. Tap one and the list moves there: the newest row is the present, any older one a read-only preview of that past state, shown in place under a floating bar. The bar names the point you stand on — in full and wrapping like the rows, since it is the same change name that must set one point apart from the next. A marks the row you are at, so the screen answers not only what changed but where am I now; tapping a newer row walks forward again, so you can go back and return to the future at will.

In practice a preview renders a throwaway fork of the doc, checked out to that point, rather than moving the live doc — because checking out the live doc would freeze its undo history for good, so every later Annuler would silently do nothing. The fork is read-only and discarded on leaving, and while it shows, the list under the bar is inert so an edit cannot slip onto the doc behind it. A row’s frontier is its change’s last op, so a point lands just after it. From a preview Revenir au présent drops it; Restaurer ici makes it real on the live doc with a revertTo — a synced change everyone gets, which the toast’s Annuler walks back — so a private look and a shared restore stay distinct.

@testcase
def test_history_lists_changes(page):
    """The history screen names changes — a thing added, then ticked and unticked."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("checkbox", name="tente").click()   # coché
    page.get_by_role("checkbox", name="tente").click()   # décoché
    page.get_by_role("button", name="Historique").click()
    hist = page.get_by_role("dialog", name="Historique")
    assert hist.get_by_text("« tente » ajouté").count() == 1, "add not listed"
    assert hist.get_by_text("« tente » coché").count() == 1, "coché not listed"
    assert hist.get_by_text("« tente » décoché").count() == 1, "décoché not listed"
    print("  PASS: history lists changes")

@testcase
def test_history_message_shown_in_full(page):
    """A long change name wraps to fit its row, not clipped behind an ellipsis."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    long = "crème solaire pour toute la famille"
    add_thing(page, long, "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Historique").click()
    msg = page.get_by_role("dialog", name="Historique").get_by_role(
        "button", name=f{long} » ajouté").locator(".hist-msg")
    clipped = msg.evaluate(
        "el => el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1")
    assert not clipped, "the change name is clipped, not shown in full"
    print("  PASS: history message shown in full")

@testcase
def test_history_preview(page):
    """Tapping a history entry previews that past state in place; Revenir au présent goes back to the future."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Historique").click()
    page.get_by_role("dialog", name="Historique").get_by_role(
        "button", name="« tente » ajouté").click()
    assert page.get_by_text("corde").count() == 0        # previewing the point before « corde »
    assert page.get_by_role("button", name="Revenir au présent").is_visible()
    page.get_by_role("button", name="Revenir au présent").click()
    assert page.get_by_text("corde").count() == 1        # back to the present — « corde » is here again
    print("  PASS: history preview")

@testcase
def test_preview_bar_names_point_in_full(page):
    """Standing on a past point, the floating bar names it in full — not clipped behind an ellipsis."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    long = "crème solaire pour toute la famille"
    add_thing(page, long, "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Historique").click()
    page.get_by_role("dialog", name="Historique").get_by_role(
        "button", name=f{long} » ajouté").click()
    msg = page.get_by_role("dialog", name="Aperçu").locator(".viewing-msg")
    clipped = msg.evaluate(
        "el => el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1")
    assert not clipped, "the previewed point's name is clipped, not shown in full"
    print("  PASS: preview bar names point in full")

@testcase
def test_history_restore(page):
    """Restaurer ici from a preview commits that point for everyone; the toast Annuler undoes it."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Historique").click()
    page.get_by_role("dialog", name="Historique").get_by_role(
        "button", name="« tente » ajouté").click()
    page.get_by_role("button", name="Restaurer ici").click()
    assert page.get_by_text("corde").count() == 0        # restored to before « corde »
    assert page.get_by_text("tente").count() == 1        # « tente » kept
    page.get_by_role("status").get_by_role("button", name="Annuler").click()
    assert page.get_by_text("corde").count() == 1        # the restore is undone
    print("  PASS: history restore")

@testcase
def test_history_marker(page):
    """The history marks the point you are at (▸); it moves as you navigate, then back to the present."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Historique").click()
    hist = page.get_by_role("dialog", name="Historique")
    assert "▸" in hist.get_by_role("button", name="« corde » ajouté").inner_text()   # the present sits on the newest change
    assert "▸" not in hist.get_by_role("button", name="« tente » ajouté").inner_text()
    hist.get_by_role("button", name="« tente » ajouté").click()   # move back to « tente »
    page.get_by_role("dialog", name="Aperçu").get_by_role("button", name="Historique").click()
    hist2 = page.get_by_role("dialog", name="Historique")
    assert "▸" in hist2.get_by_role("button", name="« tente » ajouté").inner_text()   # the marker followed us
    assert "▸" not in hist2.get_by_role("button", name="« corde » ajouté").inner_text()
    print("  PASS: history marker")

A preview must not cost the live undo: it reads a fork, never the live doc, so Défaire still walks back the real edits after a look into the past.

@testcase
def test_preview_keeps_undo(page):
    """Previewing a past point leaves the live undo intact — Défaire still works afterwards."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    add_thing(page, "corde", "sac")
    page.get_by_role("button", name="Terminé").click()
    page.get_by_role("button", name="Historique").click()
    page.get_by_role("dialog", name="Historique").get_by_role(
        "button", name="« tente » ajouté").click()          # peek at the past
    page.get_by_role("button", name="Revenir au présent").click()
    page.get_by_role("button", name="Défaire").click()       # undo the last real edit (« corde »)
    assert page.get_by_text("corde").count() == 0            # undo still works after a preview
    print("  PASS: preview keeps undo")

The history is a tree, not a line: two phones editing at once fork it, and a later edit that has seen both merges it. So the screen draws a git-log-style rail beside the rows — a dot per change on a structural lane, coloured by the device that made it, with an edge down to each change it followed. A lane follows the branch, not the author: a linear run of edits keeps a single lane however many devices took turns on it, a second lane opens only where edits truly diverged, and it closes again on the change that reconciled them. Colour tells who, the lanes tell where it split, and the edges where it rejoined. Only the last fifty changes are drawn, old history falling off the top; an edge to a parent older than that is dropped, so its child reads as a stub with no line rising above it.

@testcase
def test_history_graph_colors_by_peer(page):
    """Two devices editing a shared room paint their changes in distinct colours."""
    sync = require_sync_server()
    browser = page.context.browser
    with two_contexts(browser) as (ca, cb):
        a = open_app(ca, f"{BASE_URL}?sync_url={sync}/couleurs")
        b = open_app(cb, f"{BASE_URL}?sync_url={sync}/couleurs")
        edit(a); add_thing(a, "coteA")
        edit(b); add_thing(b, "coteB")
        expect(a.get_by_text("coteB")).to_be_visible(timeout=8000)   # converged: A has B's change
        a.get_by_role("button", name="Historique").click()
        fills = a.locator(".hist-graph circle").evaluate_all(
            "els => [...new Set(els.map(e => getComputedStyle(e).fill))]")
        assert len(fills) >= 2, fills                                # a colour per device
        print("  PASS: history graph colors by peer")

@testcase
def test_history_graph_branches(page):
    """Concurrent offline edits fork the graph into two lanes."""
    sync = require_sync_server()
    browser = page.context.browser
    with two_contexts(browser) as (ca, cb):
        a = open_app(ca, f"{BASE_URL}?sync_url={sync}/branches")
        b = open_app(cb, f"{BASE_URL}?sync_url={sync}/branches")
        edit(a); add_thing(a, "base")
        expect(b.get_by_text("base")).to_be_visible(timeout=8000)    # a shared starting point
        ca.set_offline(True); cb.set_offline(True)
        add_thing(a, "brancheA")                                     # a is already in edit mode
        edit(b); add_thing(b, "brancheB")                            # concurrent, each offline
        ca.set_offline(False); cb.set_offline(False)
        expect(a.get_by_text("brancheB")).to_be_visible(timeout=8000)   # reconciled
        a.get_by_role("button", name="Historique").click()
        cx = a.locator(".hist-graph circle").evaluate_all(
            "els => [...new Set(els.map(e => e.getAttribute('cx')))]")
        assert len(cx) >= 2, cx                                      # two lanes: the branches diverged
        print("  PASS: history graph branches")

@testcase
def test_history_graph_merge(page):
    """An edit that has seen both branches renders a merge — one node with two incoming edges."""
    from collections import Counter
    sync = require_sync_server()
    browser = page.context.browser
    with two_contexts(browser) as (ca, cb):
        a = open_app(ca, f"{BASE_URL}?sync_url={sync}/fusion")
        b = open_app(cb, f"{BASE_URL}?sync_url={sync}/fusion")
        edit(a); add_thing(a, "base")
        expect(b.get_by_text("base")).to_be_visible(timeout=8000)
        ca.set_offline(True); cb.set_offline(True)
        add_thing(a, "brancheA")
        edit(b); add_thing(b, "brancheB")
        ca.set_offline(False); cb.set_offline(False)
        expect(a.get_by_text("brancheB")).to_be_visible(timeout=8000)
        add_thing(a, "fusion")                                       # A edits having seen both branches
        a.get_by_role("button", name="Historique").click()
        ends = a.locator(".hist-graph .hist-edge").evaluate_all(
            "els => els.map(e => e.getAttribute('x2') + ',' + e.getAttribute('y2'))")
        assert max(Counter(ends).values()) >= 2, ends               # two edges reach one node: a merge
        print("  PASS: history graph merge")

A linear chain holds one lane whoever wrote it: a change synced from another device and built upon continues the line, it does not open a branch. Only concurrent edits do.

@testcase
def test_history_graph_linear_one_lane(page):
    """A linear chain across two devices stays a single lane — no rightward staircase."""
    sync = require_sync_server()
    browser = page.context.browser
    with two_contexts(browser) as (ca, cb):
        a = open_app(ca, f"{BASE_URL}?sync_url={sync}/lineaire")
        b = open_app(cb, f"{BASE_URL}?sync_url={sync}/lineaire")
        edit(a); add_thing(a, "un")
        expect(b.get_by_text("un")).to_be_visible(timeout=8000)   # B has A's change
        edit(b); add_thing(b, "deux")                             # built on top of it — linear
        expect(a.get_by_text("deux")).to_be_visible(timeout=8000)
        add_thing(a, "trois")                                     # A (still in edit mode) builds on B's
        a.get_by_role("button", name="Historique").click()
        cx = a.locator(".hist-graph circle").evaluate_all(
            "els => [...new Set(els.map(e => e.getAttribute('cx')))]")
        assert len(cx) == 1, cx                                   # one lane for the whole linear chain
        print("  PASS: history graph linear one lane")

A device keeps a stable identity across reloads, so its whole trail is one lane in one colour — a reload is the same packer, not a new one.

@testcase
def test_graph_stable_across_reload(page):
    """Reloading is the same device: the rail stays one colour, not a new one each time."""
    clear_state(page)
    edit(page)
    add_tag(page, "sac")
    add_thing(page, "tente", "sac")
    page.wait_for_timeout(300)                       # let the snapshot flush to IndexedDB
    page.reload()
    page.wait_for_selector("#app > *")
    edit(page)
    add_thing(page, "corde", "sac")                 # added after the reload — still this device
    page.get_by_role("button", name="Historique").click()
    fills = page.locator(".hist-graph circle").evaluate_all(
        "els => [...new Set(els.map(e => getComputedStyle(e).fill))]")
    assert len(fills) == 1, fills                   # one device, one colour across the reload
    print("  PASS: graph stable across reload")

The data behind all this is one read of the change log. historyChanges flattens getAllChanges into rows sorted by lamport, each carrying its message and time and the peer=/=counter=/=length=/=deps the rail needs; relativeTime turns a timestamp into à l’instant, il y a 3 min, and so on.

function relativeTime(ts){
    if(!ts) return '';
    const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
    if(s < 60) return "à l'instant";
    if(s < 3600) return 'il y a ' + Math.floor(s / 60) + ' min';
    if(s < 86400) return 'il y a ' + Math.floor(s / 3600) + ' h';
    return 'il y a ' + Math.floor(s / 86400) + ' j';
}

function historyChanges(){
    const out = [];
    for(const [, changes] of doc.getAllChanges())
        for(const c of changes)
            out.push({ msg: c.message, ts: c.timestamp, lamport: c.lamport,
                       peer: String(c.peer), counter: c.counter, length: c.length, deps: c.deps,
                       frontier: [{ peer: c.peer, counter: c.counter + c.length - 1 }] });
    return out.sort((a, b) => (a.lamport ?? a.ts) - (b.lamport ?? b.ts));
}

Opening the screen, moving along it, and restoring from it are the navigation. One history-mode entry covers both the list and a preview, and its closer always returns to the present, dropping any open preview. sameFrontier compares two heads op-for-op — it decides both which row is the newest and which row we currently stand on.

function endPreview(){ previewDoc = null; viewingFrontier = null; }

function openHistory(){
    if(!viewingFrontier) openScreen(() => { historyOpen = false; histRowHeights = null; endPreview(); paint(); });
    historyOpen = true;
    paint();
}

function sameFrontier(a, b){
    return a.length === b.length && a.every((x, i) => b[i] && x.peer === b[i].peer && x.counter === b[i].counter);
}

function goToPoint(c){
    const all = historyChanges();
    const latest = all.length ? all[all.length - 1].frontier : null;
    if(latest && sameFrontier(c.frontier, latest)){ backToPresent(); return; }
    const fork = new LoroDoc();
    fork.import(doc.export({ mode: 'snapshot' }));   // a preview reads a fork, never the live doc
    fork.checkout(c.frontier);
    previewDoc = fork;
    viewingFrontier = c.frontier;
    viewingLabel = c.msg || 'modification';
    historyOpen = false;
    paint();
}

function backToPresent(){ goBack(); }   // the closer drops the preview and repaints

function restoreHere(){
    const f = viewingFrontier;
    endPreview();
    doc.revertTo(f);
    goBack();
    notify('État restauré');
}

The rail is laid out from the shown changes by packing lanes the way git log --graph does: walking newest to oldest, a lane holds the change it next expects, so a linear chain keeps one lane whoever wrote it, a fork opens a second, and a merge frees the extras. A change is a dot in its lane, coloured by the device that made it (cycled from the palette), with an edge running down to every change it followed. A dot sits at the vertical centre of its row; because a row grows to fit its wrapped name rather than to a fixed grid, historyGraph takes the measured row heights, stacks them to find each centre, and falls back to a default until that measurement exists. depNode finds the shown change a dependency lands in — its op counter inside that change’s span; a dependency whose parent is off the top draws nothing.

const PEER_COLORS = ['#4c6ef5', '#e8590c', '#0ca678', '#ae3ec9', '#f08c00', '#1098ad'];
const HIST_ROW_H = 40, HIST_LANE_W = 20, HIST_PAD = 14, HIST_DOT_R = 5;

function historyGraph(shown, here, heights){
    const rows = shown.slice().reverse();                 // newest first (row 0 at the top)
    const peers = [...new Set(shown.map(n => n.peer))];    // colour is per device; the lane is per branch
    const color = p => PEER_COLORS[peers.indexOf(p) % PEER_COLORS.length];
    const rowH = i => (heights && heights[i]) || HIST_ROW_H;   // a row's measured height, or the default before layout
    const top = []; for(let i = 0; i < rows.length; i++) top[i] = i ? top[i-1] + rowH(i-1) : 0;
    const rowY = i => top[i] + rowH(i) / 2;                // a dot sits on its row's centre, whatever its height
    const laneX = c => HIST_PAD + c * HIST_LANE_W;
    const rowOf = new Map(rows.map((n, i) => [n, i]));
    const depNode = d => rows.find(n => n.peer === String(d.peer) && d.counter >= n.counter && d.counter < n.counter + n.length);

    const lanes = [], col = new Map();
    let maxCol = 0;
    for(const n of rows){
        let c = lanes.indexOf(n);
        if(c === -1){ c = lanes.indexOf(null); if(c === -1) c = lanes.length; }
        else for(let k = c + 1; k < lanes.length; k++) if(lanes[k] === n) lanes[k] = null;   // children converging on a fork
        col.set(n, c);
        const parents = (n.deps || []).map(depNode).filter(Boolean);
        lanes[c] = parents[0] || null;                    // the first parent continues this lane
        for(let k = 1; k < parents.length; k++){          // a merge: each extra parent claims a lane
            let pk = lanes.indexOf(parents[k]);
            if(pk === -1){ pk = lanes.indexOf(null); if(pk === -1) pk = lanes.length; lanes[pk] = parents[k]; }
        }
        maxCol = Math.max(maxCol, c, lanes.length - 1);
    }

    const edges = [];
    rows.forEach((n, i) => {
        for(const d of (n.deps || [])){
            const p = depNode(d);
            if(!p) continue;                              // parent off the top of the list
            edges.push({ x1: laneX(col.get(p)), y1: rowY(rowOf.get(p)), x2: laneX(col.get(n)), y2: rowY(i), color: color(n.peer) });
        }
    });
    const dots = rows.map((n, i) => ({ cx: laneX(col.get(n)), cy: rowY(i), color: color(n.peer),
                                       cur: sameFrontier(n.frontier, here) }));
    const height = rows.length ? top[rows.length-1] + rowH(rows.length-1) : 0;
    return { rows, edges, dots, width: HIST_PAD * 2 + maxCol * HIST_LANE_W, height };
}

The sheet paints the rail as an SVG pinned to the left, its rows the same tappable changes beside it. The row heights the rail needs only exist once the browser has laid the rows out, so the rail is drawn twice: the first paint falls back to the default height, then relayoutHistory reads the heights the rows actually took and repaints, so every dot settles onto the centre of its row whatever its name ran to. Only the last fifty are shown, older ones counted off below. A floating bar rides a preview, offering a jump elsewhere, a restore, or a step back to the present.

function historyView(){
    const all = historyChanges();
    const shown = all.slice(-50);                       // oldest to newest, in order for the edges
    const more = all.length - shown.length;
    const here = viewingFrontier || doc.frontiers();
    const g = historyGraph(shown, here, histRowHeights);
    return html`<div class="sheet-back" onclick=${backdropClose(goBack)}><div class="sheet history" role="dialog" aria-label="Historique">
      <p class="sheet-msg">Historique</p>
      <div class="hist-graph-wrap" style=${`min-height:${g.height}px`}>
        <svg class="hist-graph" width=${g.width} height=${g.height} style=${`width:${g.width}px;height:${g.height}px`}>
          ${g.edges.map(e => svg`<line class="hist-edge" x1=${e.x1} y1=${e.y1} x2=${e.x2} y2=${e.y2} stroke=${e.color} stroke-width="2"/>`)}
          ${g.dots.map(d => svg`<circle cx=${d.cx} cy=${d.cy} r=${HIST_DOT_R} fill=${d.color} stroke=${d.cur ? '#1b1d2e' : d.color} stroke-width=${d.cur ? 3 : 1}/>`)}
        </svg>
        <div class="hist-rows" style=${`margin-left:${g.width}px`}>
          ${g.rows.map(c => { const cur = sameFrontier(c.frontier, here); return html`<button class=${'hist-row' + (cur ? ' hist-here' : '')} onclick=${() => goToPoint(c)}>
            <span class="hist-msg">${cur ? '▸ ' : ''}${c.msg || 'modification'}</span>
            <span class="hist-time">${relativeTime(c.ts)}</span></button>`; })}
        </div>
      </div>
      ${more > 0 ? html`<p class="hist-more">+ ${more} plus anciennes</p>` : ''}
      <button class="sheet-quiet" onclick=${() => goBack()}>Fermer</button>
    </div></div>`;
}

function relayoutHistory(){
    const hs = [...app.querySelectorAll('.hist-row')].map(r => r.offsetHeight);
    if(histRowHeights && hs.length === histRowHeights.length
       && hs.every((v, i) => Math.abs(v - histRowHeights[i]) < 1)) return;   // the rail already fits these rows
    histRowHeights = hs;
    paint();                                             // repaint the rail against the just-measured rows
}

function viewingBar(){
    return html`<div class="viewing-bar" role="dialog" aria-label="Aperçu">
      <span class="viewing-msg">Aperçu — ${viewingLabel}</span>
      <span class="viewing-acts">
        <button onclick=${() => { historyOpen = true; paint(); }}>Historique</button>
        <button onclick=${restoreHere}>Restaurer ici</button>
        <button onclick=${backToPresent}>Revenir au présent</button>
      </span>
    </div>`;
}

The relay

Two phones are almost never awake at the same time, so neither can be the thing the other syncs against. What they both need is a peer that never closes and stays reachable — and because Loro merges, that peer can be the dumbest participant in the system.

Sync server

The relay is a persisting Loro peer, tangled from here to the Dagger module that builds it. It arbitrates nothing — Loro merges, so it is a durable peer and not an authority. A connection does only this:

The snapshot going out first is what lets a late-joiner — or an all-offline reopen — still find the history. The rename is what keeps a .loro from ever being seen half-written, so a plain copy of the data dir is always a whole snapshot. Authorization never reaches this process at all: traefik’s ForwardAuth settles it on the upgrade, against one grant per room.

A room is one LoroDoc and the file it lives in, opened the first time someone asks for it by name — and read back from disk if it was there before the last restart.

const rooms = new Map();

function roomFor(url) {
    let room = rooms.get(url);
    if (room) return room;
    const doc = new LoroDoc();
    const file = join(DATA_DIR, encodeURIComponent(url) + '.loro');
    if (existsSync(file)) doc.import(readFileSync(file));
    room = { doc, file, peers: new Set() };
    rooms.set(url, room);
    return room;
}

Handling a connection is that exchange, once. The only judgement call in it is the frame that will not import: it is dropped where it lands, so one peer sending nonsense cannot take the room down for the others.

const wss = new WebSocketServer({ port: PORT });

wss.on('connection', (ws, req) => {
    const room = roomFor(req.url || '/');
    room.peers.add(ws);
    const snapshot = room.doc.export({ mode: 'snapshot' });
    if (snapshot.length) ws.send(snapshot, { binary: true });
    ws.on('message', (data) => {
        const bytes = new Uint8Array(data);
        try { room.doc.import(bytes); } catch { return; }
        const snapshot = room.doc.export({ mode: 'snapshot' });
        writeFileSync(room.file + '.tmp', snapshot);
        renameSync(room.file + '.tmp', room.file);
        for (const peer of room.peers)
            if (peer !== ws && peer.readyState === peer.OPEN)
                peer.send(data, { binary: true });
    });
    ws.on('close', () => { room.peers.delete(ws); });
});

Around those two, the process is told where it lives rather than deciding: Nomad picks the port and owns the directory, so both arrive from the environment.

import { WebSocketServer } from 'ws';
import { LoroDoc } from 'loro-crdt';
import { mkdirSync, existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
import { join } from 'node:path';

const PORT = parseInt(process.env.PORT || '8048', 10);
const DATA_DIR = process.env.DATA_DIR || '/app/data';
mkdirSync(DATA_DIR, { recursive: true });

const rooms = new Map();

function roomFor(url) {
    let room = rooms.get(url);
    if (room) return room;
    const doc = new LoroDoc();
    const file = join(DATA_DIR, encodeURIComponent(url) + '.loro');
    if (existsSync(file)) doc.import(readFileSync(file));
    room = { doc, file, peers: new Set() };
    rooms.set(url, room);
    return room;
}

const wss = new WebSocketServer({ port: PORT });

wss.on('connection', (ws, req) => {
    const room = roomFor(req.url || '/');
    room.peers.add(ws);
    const snapshot = room.doc.export({ mode: 'snapshot' });
    if (snapshot.length) ws.send(snapshot, { binary: true });
    ws.on('message', (data) => {
        const bytes = new Uint8Array(data);
        try { room.doc.import(bytes); } catch { return; }
        const snapshot = room.doc.export({ mode: 'snapshot' });
        writeFileSync(room.file + '.tmp', snapshot);
        renameSync(room.file + '.tmp', room.file);
        for (const peer of room.peers)
            if (peer !== ws && peer.readyState === peer.OPEN)
                peer.send(data, { binary: true });
    });
    ws.on('close', () => { room.peers.delete(ws); });
});

console.log(`lorosync (persisting) on :${PORT}, data in ${DATA_DIR}`);

Its one dependency beyond ws is loro-crdt — the same CRDT the browser runs, here under node.

{
    "name": "lorosync",
    "private": true,
    "type": "module",
    "dependencies": {
        "loro-crdt": "^1",
        "ws": "^8"
    }
}

One number decides which relay runs where — the tag that is built and published, the image the Nomad job pulls, the image the tests spin up. It is written once here and noweb carries it into each of them, so a bump moves them together instead of leaving one of them a version behind.

0.1.2

The image is built by a Dagger mixin, tangled here beside the server it packages. A mixin rather than an object of its own, so the commands stay flat — dagger call lorosync-build and not a nested path — and so the publish and load helpers already on the Docker object are reached through self. The image itself is node with the two dependencies installed, and it runs as the unprivileged node user rather than as root — which is what the disk below has to accommodate.

import dagger
from dagger import dag, function

from .image import ImageMixin


class LorosyncMixin(ImageMixin):
    """lorosync functions mixed into the main Docker object."""

    @function
    def lorosync_build(self, platform: str = "linux/amd64") -> dagger.Container:
        """Build the lorosync image for PLATFORM."""
        src = dag.current_module().source().directory("lorosync")
        ctr = dag.container(platform=dagger.Platform(platform)).from_("node:20-slim")
        ctr = (
            ctr.with_exec(["mkdir", "-p", "/app"])
            .with_exec(["chown", "-R", "1000:1000", "/app"])
        )
        ctr = dag.lib().as_user(ctr, username="node").with_workdir("/app")
        ctr = (
            ctr.with_file("/app/package.json", src.file("package.json"), owner="1000:1000")
            .with_exec(["npm", "install", "--omit=dev"])
            .with_file("/app/server.mjs", src.file("server.mjs"), owner="1000:1000")
        )
        return ctr.with_entrypoint(["node", "server.mjs"])

Shipping it is the other half. One tag is published for several architectures, while the local load takes the host’s own build, so the cluster and the machine running the tests can ask for the same image and each get one that runs. The version rides as the tag default argument, so no command ever carries it.


@function
async def lorosync_publish(self, repository: str = "lorosync", tag: str = "0.1.2") -> str:
    """Publish the multi-arch lorosync image to REPOSITORY, tagged TAG and latest."""
    return await self._publish_image(self.lorosync_build, repository, tag)

@function
async def lorosync_load(self, sock: dagger.Socket, tag: str = "0.1.2") -> str:
    """Load the host-arch lorosync build into local docker as konubinix/lorosync,
    tagged TAG and latest."""
    return await self._load_image("lorosync", self.lorosync_build, sock, tag)

The Nomad job that runs it is tangled here too, and what it has to get right is the disk. A room lives nowhere else once the phones have closed, so the task’s storage must outlive the task: an ephemeral disk marked sticky is kept across restarts, and migrate carries it along if the allocation moves to another node.

ephemeral_disk {
  migrate = true
  sticky  = true
  size    = 50
}

That disk is the allocation’s own directory, bind-mounted 0777 so the non-root node user inside the image can write there. Logs share it, and a relay that talks all day would fill it with them, so it keeps only two small files.

logs {
  max_files     = 2
  max_file_size = 10
  disabled      = false
}

Reaching the relay from a phone goes through traefik, on two routers — plain and HTTPS — both carrying the gate, so a socket cannot be opened without the room’s grant whichever way it is dialled. Nomad’s own health check is TCP: the process answers WS upgrades and nothing else, so a GET probe would have nothing to answer with.

One router serves every document, and the gate works out which one is being dialled from the address itself. A list shared with one person therefore says nothing about the next, and adding a list costs no configuration at all.

service  {
  name = "lorosync"
  tags = [
    "traefik.http.routers.${local.job-name}.entrypoints=http_stack_index",
    "traefik.http.routers.${local.job-name}.rule=PathPrefix(`/${local.root}`)",
    "traefik.http.routers.${local.job-name}.middlewares=authz-check-path",

    "traefik.http.routers.${local.job-name}-https.rule=Host(`konubinix.eu`) && PathPrefix(`/${local.root}`)",
    "traefik.http.routers.${local.job-name}-https.tls=true",
    "traefik.http.routers.${local.job-name}-https.tls.certresolver=https",
    "traefik.http.routers.${local.job-name}-https.entrypoints=https",
    "traefik.http.routers.${local.job-name}-https.tls.domains[0].main=konubinix.eu",
    "traefik.http.routers.${local.job-name}-https.middlewares=authz-check-path",
  ]
  port = "http"
  check {
    port = "http"
    name = "lorosync-${node.unique.name}"
    type = "tcp"
    interval = "1m"
    timeout  = "30s"
  }
}

The job that holds those three: one group, one task, the image pinned to the version above, and DATA_DIR pointing into the allocation directory — the sticky disk.

locals {
  root = "lorosync"
  job-name = "lorosync"
  repo = file("jobs/conf/repo")
  memory = 200
}

job "lorosync" {
  datacenters = ["dc1"]
  group "lorosync" {
    network {
      port "http" {}
    }
    update {
      healthy_deadline  = "15m"
      progress_deadline = "25m"
    }
    ephemeral_disk {
      migrate = true
      sticky  = true
      size    = 50
    }
    task "lorosync" {
      logs {
        max_files     = 2
        max_file_size = 10
        disabled      = false
      }
      driver = "docker"
      config {
        image_pull_timeout = "20m"
        image = "${local.repo}/lorosync:0.1.2"
        ports = ["http"]
      }
      env {
        PORT="${NOMAD_PORT_http}"
        DATA_DIR="${NOMAD_ALLOC_DIR}/data"
      }
      resources {
        cpu = 200
        memory = local.memory
        memory_max = 1.5 * local.memory
      }
      service  {
        name = "lorosync"
        tags = [
          "traefik.http.routers.${local.job-name}.entrypoints=http_stack_index",
          "traefik.http.routers.${local.job-name}.rule=PathPrefix(`/${local.root}`)",
          "traefik.http.routers.${local.job-name}.middlewares=authz-check-path",

          "traefik.http.routers.${local.job-name}-https.rule=Host(`konubinix.eu`) && PathPrefix(`/${local.root}`)",
          "traefik.http.routers.${local.job-name}-https.tls=true",
          "traefik.http.routers.${local.job-name}-https.tls.certresolver=https",
          "traefik.http.routers.${local.job-name}-https.entrypoints=https",
          "traefik.http.routers.${local.job-name}-https.tls.domains[0].main=konubinix.eu",
          "traefik.http.routers.${local.job-name}-https.middlewares=authz-check-path",
        ]
        port = "http"
        check {
          port = "http"
          name = "lorosync-${node.unique.name}"
          type = "tcp"
          interval = "1m"
          timeout  = "30s"
        }
      }
    }
  }
}

Built for the host to feed the test fixture, published multi-arch to the registry, then started on Nomad:

dagger -m /home/sam/perso/perso/nomad/docker call lorosync-load --sock /var/run/docker.sock
dagger -m /home/sam/perso/perso/nomad/docker call lorosync-publish
clk nd start lorosync

Reading a room

A room is not the browser’s property. It is bytes, and anything that speaks Loro can import them — half of why the stack was picked. Here is that claim made concrete outside a browser: a dozen lines of node, pointed at a room, printing what it holds — how many tags, things and memberships, and the tag names. Pointed at the room we pack from, that is 25 tags, 379 things and 383 memberships. An empty room sends nothing, so a silent three seconds is reported as empty rather than hung. The room’s grant gates the socket, so the reader is handed a cookie through the COOKIE env var and puts it on the handshake.

import { LoroDoc } from 'loro-crdt';
import WebSocket from 'ws';

const [, , wsUrl] = process.argv;
console.log('reader connecting to', wsUrl, 'cookie:', process.env.COOKIE ? 'yes' : 'no');
const doc = new LoroDoc();
const ws = new WebSocket(wsUrl, process.env.COOKIE ? { headers: { Cookie: process.env.COOKIE } } : undefined);
ws.binaryType = 'arraybuffer';
let got = false;

ws.on('message', (data) => {
    doc.import(new Uint8Array(data));
    got = true;
    const j = doc.toJSON();
    const tg = j.tags || {}, it = j.items || {}, ms = j.tagged || {};
    console.log('tags:', Object.keys(tg).length, 'items:', Object.keys(it).length, 'memberships:', Object.keys(ms).length);
    console.log('names:', Object.values(tg).map((s) => s.name).join(' | '));
    ws.close();
    process.exit(0);
});
ws.on('error', (e) => { console.error('ws error:', e.message); process.exit(1); });
setTimeout(() => { console.log(got ? 'done' : 'NO SNAPSHOT (room empty on this instance)'); process.exit(0); }, 3000);

Backing up a room

Once every phone has closed, the relay holds the only full copy of a room’s history, and it keeps it on the task’s sticky ephemeral disk — best-effort, not durable: a lost node, or an allocation garbage-collected after the job is stopped, takes the room with it, and the phones’ evictable IndexedDB caches are all that would remain. So the room is pushed off the host on a schedule, the way the rest of the stack already is.

Hourly, at half past, is often enough for a list that changes a few times a day, and a run that is still going when the next hour comes round is not doubled up.

periodic {
  crons             = ["30 */1 * * * *"]
  prohibit_overlap = true
  time_zone = "Europe/Paris"
}

What runs then is redisbackup’s sibling — the same shape the stack’s other backups have: find the running allocation, lift the data out of it with nomad alloc fs, and post it to the upload service that keeps the archives, whose address the job hands it in SERVICEMESH_IP. It parts from redis in one place, and that place is the point of the relay’s atomic write — redis must save and copy through the alloc dir because a live dump.rdb can be caught mid-write, but the relay renames each snapshot into place, so every .loro on disk is already whole. Nothing has to be quiesced: tar the data dir, ship it, delete the tar — three commands, run from the nomadclient image because it is the one carrying the nomad CLI they lean on.

#!/usr/bin/env sh

set -eux

DATE="$(date +'%Y%m%d-%H%M')"

job=lorosync
ALLOC="$(nomad status "${job}"|grep running|grep "${job}"|cut -f1 -d' ')"
nomad exec -t=false -i=false -task "${job}" ${ALLOC} sh -c 'tar -C ${NOMAD_ALLOC_DIR}/data -cf ${NOMAD_ALLOC_DIR}/backup.tar .'
nomad alloc fs ${ALLOC} alloc/backup.tar | curl --data-binary @- "http://${SERVICEMESH_IP}:9706/upload?filename="${job}"/${DATE}-fr.tar"
nomad exec -t=false -i=false -task "${job}" ${ALLOC} sh -c 'rm ${NOMAD_ALLOC_DIR}/backup.tar'

Restore never clobbers: a room is a CRDT. Untar a saved snapshot back into the data dir before the relay has loaded that room — a fresh task — and roomFor imports it on the first connection; on a live relay that already holds the room in memory, send it over the socket the way a peer sends its own updates. Either way Loro merges rather than overwrites.

Building and shipping it

Nothing above reaches a phone on its own. The note is the program: it tangles into the files a browser loads, and it also carries the local slot to iterate in and the harness that drives the app like a thumb before any phone sees it.

Dev environment

Iterating must not mean publishing, so the note tangles twice from the same blocks: a scratch slot under /var/run/user/1000/triggerlist/, served at http://localhost:9682/debug/triggerlist/, and in parallel the prod directory ~/perso/org-publish/github/triggerlist/. What is tried locally is therefore literally what ships. Only the scratch slot also gets triggerlist.py, the test driver: it carries a nix-shell shebang, so it must be run directly (./triggerlist.py) and never through python3, which would bypass the Playwright deps the shebang pulls in.

Test harness

The tests drive the app in a phone-sized viewport, through the handles a thumb has. Registering a test, finding a browser, running the set and dumping a failure are the same problems in every note that has tests, so they are not solved again here: the shared blocks are woven in.

import atexit, os, re, shutil, socket, subprocess, sys, threading, time, uuid
from contextlib import contextmanager
nil
from playwright.sync_api import sync_playwright, expect
BASE_URL = os.environ.get("TRIGGERLIST_URL", "http://localhost:9682/debug/triggerlist/")
PHONE_VIEWPORT = {"width": 400, "height": 800}

What is specific to this app is the device: its whole state lives there, so a test starts by emptying both stores and reloading into a phone that has never seen this list — otherwise one run’s leftovers would decide the next one’s result. A second device, which the sync tests need, is opened the same way: go there, and wait until the app has actually mounted before asking it anything.

That second device is given a long rope. It is a browser context with nothing cached, so its very first paint waits on the whole module graph coming down from esm.sh, and a cap tight enough to catch a hung boot would also fail a merely cold one. Thirty seconds tells the two apart; the app still has to mount, only not on the first device’s schedule.

nil
nil

def clear_state(page):
    page.goto(BASE_URL)
    page.wait_for_selector("#app > *")
    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("#app > *")

def open_app(ctx, url):
    page = ctx.new_page()
    page.set_default_timeout(5000)
    page.goto(url, timeout=30000)
    page.wait_for_selector("#app > *", timeout=30000)
    return page

Above that, the tests say what a person does rather than what the DOM holds, and each gesture goes through the visible label or the role a thumb would aim at — so a test can only pass along a path someone could walk. Getting a thing onto the list is three of them: open edit mode, make a tag, add a thing to that card. The two that reveal a field have to cope with an actuator that toggles, since tapping it again would close what it just opened, so they open it only when its field is not already up. Handed no tag at all, add_thing takes the main screen’s own field — the path a person takes when they are not filing anything, and the one that needs no mode.

def edit(page):
    # exact: inside a trip, "Modifier la sortie" is a different control
    page.get_by_role("button", name="Modifier", exact=True).click()

def add_tag(page, name):
    field = page.get_by_placeholder("Nouvelle étiquette")
    if field.count() == 0:
        page.get_by_role("button", name="Nouvelle étiquette").click()
    field.fill(name)
    page.get_by_role("button", name="Créer l'étiquette").click()

def add_thing(page, name, tag=None):
    if tag is None:
        field = page.get_by_placeholder("Ajouter une chose")
        field.fill(name)
        field.press("Enter")
        return
    card = page.locator("section").filter(
        has=page.get_by_role("heading", name=tag))
    field = card.get_by_placeholder("Nouvelle chose")
    if field.count() == 0:
        card.get_by_role("button", name="Nouvelle chose").click()
    field.fill(name)
    card.get_by_role("button", name="Créer la chose").click()

A trip exists only through the planner, so a test walks it: name it, tap the tags it gathers, close. Closing is the device’s Back — the planner offers no button of its own, and a test that pressed one would be walking a path the app does not have — and it lands in the trip, so a test wanting the catalog goes back once more. Focusing a trip afterwards is a tap on its chip.

def close_planner(page):
    page.go_back()
    page.wait_for_function("() => !document.querySelector('.planner')")

def plan_trip(page, name, tag_names):
    page.get_by_role("button", name="Planifier une sortie").click()
    page.get_by_placeholder("Nom de la sortie").fill(name)
    chips = page.locator(".planner .planner-tags")
    for t in tag_names:
        chips.get_by_role("button", name=t).click()
    close_planner(page)

def open_trip(page, name):
    page.locator(".chip", has_text=name).click()

Reordering is only ever done by dragging, so a test has to do what the finger does: take an item by its grip and drop it on the grip of the one it should sit at.

def drag_reorder(page, container_sel, from_idx, to_idx):
    items = page.locator(f"{container_sel} .reorder-item")
    src = items.nth(from_idx).locator(".reorder-grip")
    dst = items.nth(to_idx).locator(".reorder-grip")
    src.drag_to(dst)

Checking that a phone keeps trying to reconnect needs a server that is reachable and useless: one that accepts a connection and drops it at once, noting the time. The client then retries forever, and the intervals between those notes are the backoff.

def counting_refuser():
    srv = socket.socket()
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", 0))
    srv.listen()
    hits = []
    def loop():
        while True:
            try:
                conn, _ = srv.accept()
            except OSError:
                return
            hits.append(time.monotonic())
            conn.close()
    threading.Thread(target=loop, daemon=True).start()
    return srv.getsockname()[1], hits, srv

The whole point of the app takes two devices to test at all. Two browser contexts are that, cheaply: separate storage, separate app, same machine.

@contextmanager
def two_contexts(browser):
    a = browser.new_context(viewport=PHONE_VIEWPORT)
    b = browser.new_context(viewport=PHONE_VIEWPORT)
    try:
        yield a, b
    finally:
        a.close(); b.close()

And they need something to sync through. Mocking the relay would test the mock, so the harness runs the real image in a throwaway container on a free port and points both contexts at it — one container for the whole run, stopped at exit, since starting it costs seconds and the rooms inside it are per-test anyway.

SYNC_IMAGE = "konubinix/lorosync:0.1.2"
_sync = {"url": None}

def _port_open(port, timeout=0.5):
    try:
        with socket.create_connection(("127.0.0.1", port), timeout):
            return True
    except OSError:
        return False

def require_sync_server():
    if _sync["url"]:
        return _sync["url"]
    if not shutil.which("docker"):
        raise RuntimeError("docker not found — required for the sync test")
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]
    name = f"triggerlist-lorosync-{uuid.uuid4().hex[:8]}"
    subprocess.check_call(
        ["docker", "run", "-d", "--rm", "--name", name,
         "-p", f"127.0.0.1:{port}:{port}", "-e", f"PORT={port}", SYNC_IMAGE],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    for _ in range(200):
        if _port_open(port):
            _sync["url"] = f"ws://localhost:{port}"
            atexit.register(lambda: subprocess.run(
                ["docker", "stop", name],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
            return _sync["url"]
        time.sleep(0.1)
    subprocess.run(["docker", "stop", name],
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    raise RuntimeError(f"lorosync never opened port {port}")

nil

How it all fits together

Everything up to here has been pieces. What follows is the assembly — which fragment is woven into which, and what each of them finally becomes on disk. One of the fragments has not been written above: build-hash, a short SHA of the source itself, which two of the files are stamped with.

Each of those files lands twice — once in the scratch slot, once in the prod directory — except the test driver, which only the scratch slot has any use for.

And they all start with the same constraint: there is no build step, so the three bare imports have to resolve in the browser itself. An importmap points them at esm.sh.

<script type="importmap">
  {
    "imports": {
      "loro-crdt": "https://esm.sh/loro-crdt@1",
      "idb-keyval": "https://esm.sh/idb-keyval@6",
      "uhtml": "https://esm.sh/uhtml@4"
    }
  }
</script>

The HTML shell: importmap, a loading line that the app hides once ready, the #app mount, and the module entry.

A tiny monospaced tag in the top corner shows the build-hash — the short SHA the service worker’s cache name also carries — so after a deploy you can glance at a device and read which build it actually loaded, the recurring question with a hard-caching PWA.

.build-tag{position:fixed;top:4px;right:6px;z-index:50;pointer-events:none;font:10px/1 monospace;color:#8a8ea5}

The build hash changes every build, so all we can promise of the corner tag is its shape — a seven-hex short SHA — not its digits.

@testcase
def test_build_tag_shows_hash(page):
    """A build-hash tag names the loaded build, so a device's version is legible."""
    clear_state(page)
    tag = page.locator(".build-tag")
    assert tag.count() == 1, "no build tag"
    h = (tag.text_content() or "").strip()
    assert re.fullmatch(r"[0-9a-f]{7}", h), f"build tag isn't a 7-hex hash: {h!r}"
    print("  PASS: build tag shows hash")

More files ship beside them so the app is a PWA. The manifest names it, asks the OS to open it fullscreen with no browser chrome, and — the part that makes it actually installable rather than a mere shortcut — points at icons: a browser will only offer to install a standalone app when the manifest gives it a square icon of at least 192 and 512 pixels. So it lists both, plus the scalable source, all marked maskable so the launcher can crop them to its own shape:

{
  "name": "Trigger list",
  "short_name": "Trigger list",
  "start_url": ".",
  "scope": ".",
  "display": "fullscreen",
  "background_color": "#1b1d2e",
  "theme_color": "#ffffff",
  "icons": [
    { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
    { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
    { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
  ]
}

The service worker is network-first: it tries the network, and on success caches a copy before returning it; when the network is gone it answers from that cache. It only touches what the app needs to boot — the page document and the script modules (its own app.js and the esm.sh imports) — and stays out of every other request, so a plain fetch like the sync probe reaches the network directly instead of being shadowed by the worker. It claims the page as soon as it activates, so no second load is needed before it takes effect. Its cache name carries the build hash, so every build is a fresh cache — on activation the worker deletes any cache that is not the current one, so a deploy can’t leave a device stuck on a stale build.

const CACHE = 'triggerlist-<<build-hash()>>';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(
    caches.keys()
        .then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
        .then(() => self.clients.claim())));
self.addEventListener('fetch', (e) => {
    if(e.request.method !== 'GET') return;
    if(!['document', 'script'].includes(e.request.destination)) return;
    e.respondWith(
        fetch(e.request)
            .then((res) => {
                if(res.ok){
                    const copy = res.clone();
                    caches.open(CACHE).then((c) => c.put(e.request, copy));
                }
                return res;
            })
            .catch(() => caches.match(e.request))
    );
});

Both go to the two slots beside index.html, at the app’s own path so the worker’s scope covers it:

The icon is a checklist on the app’s dark ground — one SVG, kept full-bleed (the dark square reaches every edge) so a launcher can crop it to any shape without biting into the marks. It is the source; the two PNG sizes the install criteria want are rendered from it.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  <rect width="512" height="512" fill="#1b1d2e"/>
  <g stroke="#ffffff" stroke-width="30" stroke-linecap="round" stroke-linejoin="round" fill="none">
    <polyline points="140,198 178,236 244,168"/>
    <line x1="292" y1="202" x2="372" y2="202"/>
    <polyline points="140,320 178,358 244,290"/>
    <line x1="292" y1="324" x2="372" y2="324"/>
  </g>
</svg>

The two PNGs are derived, not hand-kept: after tangling icon.svg, this block rasterizes it to 192 and 512 into both slots. Re-run it whenever the icon changes.

for dir in /var/run/user/1000/triggerlist ~/perso/org-publish/github/triggerlist; do
  magick -background none "$dir/icon.svg" -resize 192x192 "$dir/icon-192.png"
  magick -background none "$dir/icon.svg" -resize 512x512 "$dir/icon-512.png"
done