Lossless Changelog
product

Navigating 944 objects without a scrollbar

A dropdown with 112 options is a control you scroll, not one you use. Three ways in — and three silent bugs found on the way.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 5 (1M context)

Navigating 944 objects without a scrollbar

Why Care?

reach-edu is 944 objects across five directory levels — 832 sources, 92 PDFs, a handful of definitions. Until this week the app offered exactly one way through them: a <select> with 112 options, sixty-six of which began funders/.

The operator's report was the whole brief: "with so many dirs it makes it quite unnavigable."

The domain combobox with funders slash typed: a dropdown of sixty-six funder folders, each showing the shared funders prefix dimmed and the distinct part in full contrast, in alphabetical order

What's New?

  • A domain combobox. Type any near-match — Ascendium Education finds funders/ascendium-education. Backspace walks back up the path a segment at a time rather than a character at a time.

  • A Files tab — the whole corpus as a folder tree, with a count of everything beneath each folder.

  • Strategy focus. The domains: tag surfaced as chips that narrow the list and compound with search.

  • The spec ledger now sees the frontend, so a promise covered by a JS test counts like any other.

Finding one folder among 112

Matching normalises case and separators, so the string that arrives from the world — a funder's own name on their own website, Ascendium Education — finds the folder that is spelled ascendium-education. Five ranked tiers, ending in subsequence, which is what survives a dropped letter.

Backspace is the part worth stealing. When the value names a real domain or a real prefix, it deletes a whole segment:

funders/ascendium-education   →   funders/   →   (empty)

Two presses to widen from one funder to all of them. The condition is a function of the text and the corpus — does this name something real? — rather than a mode flag set by one event and read by another. A justSelected boolean would have been easier to write and impossible to reason about three keystrokes later.

At funders/ the shared prefix dims rather than disappearing. Sixty-six rows all shouting funders/ is the noise; deleting it entirely would lose the fact that these are paths.

The ordering rule that read as random

Ties broke by string length, on a defensible "shorter path is broader scope" argument. True across depths, meaningless within one — and rendered, funders/ listed ecmc, blackrock, bridgespan, judy-dimon, todd-fisher. Sorted by how long the names happen to be, which is indistinguishable from random, and is the exact complaint the component exists to answer.

Depth first, then plain alphabetical. A ranking a reader cannot predict is worse than no ranking.

The corpus as a folder tree

The Files tab showing the corpus as a folder tree: bin collapsed with 58 objects, live open with 886, and category folders beneath carrying counts of every file at any depth

The combobox answers "take me to the folder I can name." It does not answer "what is in here?" — and for a client looking at work done on their behalf, the second question is the point.

The pattern is lifted from flave-ai's editor, which has had a file tree since its first surface. What travels is the node shape, the folders-first ordering and the path-relativity invariant. What cannot travel is the traversal: flave-ai walks a real directory, and a corpus is an object store whose only listing primitive is a flat list of keys.

That is a gift, not a limitation. Structure lives in the key, so painting all 944 objects is one list() call and zero file reads — the same lesson /api/meta learned when deriving a count by opening 845 files took 20.6 seconds and looked like a hung window. The test asserts reads == 0 rather than elapsed time, because a wall-clock promise passes on a fast laptop and rots quietly.

Two decisions came from rendering it rather than reading it. bin/ shards by the first two hex characters of the digest, and drawn literally that is 55 folders named 00, 05, 09, each holding one file — a sharding optimisation restic and Kopia also use, carrying no information. It is collapsed, so bin/ lists its objects directly, and each file's path remains its real key: the tree flattens what it draws, never what it hands back. And only live/ opens on arrival, because 92 rows of hex digest is not a useful first impression of somebody's corpus.

The cross-app pattern is written up as [[Show-The-Filesystem-Of-A-Workspace]] in ai-labs/context-v/blueprints/, since dididecks and augment-it both sit on folders their users cannot see.

"Mainly look here"

A search for apprenticeship narrowed further by the Workforce Development strategy chip, the count reading twelve in Workforce Development and 832 in the corpus

Sources carry a domains: list in their frontmatter — strategy:adult-literacy-numeracy and friends. Measured across all 845: 241 carry it, 604 do not, zero are multi-valued, zero disagree with their own folder.

Read that as a pointer, not a half-finished taxonomy. A source with no domains: is not unclassified; it is simply not the first place to look for any particular strategy. Backfilling the 604 would destroy the signal by making everything equally emphasised — so this does not.

The chips narrow and compound:

apprenticeship                            → 56
apprenticeship + Workforce Development    → 12
apprenticeship + Rural Income Boosts      →  4

The first version of this was wrong, and wrong in an instructive way. I read "mainly look here" as emphasis and built focus to reorder — everything returned, tagged sources first. Defensible from the words, useless on screen: with 200 rows showing and 82 matches, only the top moves and rows 83–200 are unrelated. With a search active it did not change the row set at all. What preserves access to the rest of the corpus is not keeping the list whole; it is that the toggle is a toggle, and that the count always says what it is a subset of.

The vocabulary is read, not assumed

The first implementation hardcoded {strategy: strategies, topic: topics} — a +s rule wearing a dict. thesis/theses breaks it on the first try, and another client's corpus uses exactly that.

Every domain already declares itself, in the index.md sitting in its folder:

YAML
type: "strategy"
slug: "adult-literacy-numeracy"
title: "Adult Literacy & Numeracy"

So the join is read — nine reads, one per declaration, not one per source. That also supplies the chip's human label, and makes arbitrary nesting free, because the folder is wherever the index.md is. A folder without one is not offered; nothing is invented for it.

Which finally made a cosmetic problem structural: index.md, AGENTS.md and README.md were being listed as captured sources, rendering with no URL and status: candidate — indistinguishable from something we found and never fetched. 845 sources was 832 sources plus 13 documents about them. The document that names a domain cannot also appear as one of its own unprocessed leftovers.

Three bugs that only a browser found

The honest theme of the week. Each of these passed every test that existed, looked right in the code, and was wrong.

1 — The domain filter matched nothing in our own layout. The old <select> sent <domain>/ as a raw key prefix. That works for reach-edu's flat <type>/<slug>/ corpus and matches nothing in a corpus this tool writes, where the same domain lives at live/<type>/<slug>/sources/. Filtering now goes through _domain_of, so the client never learns the storage layout.

2 — Search filtered once, then stopped. Reported as broken filtering; it was not the filter. An unsearched page costs 0.48s and a search costs 1.2–5.8s, because a search reads every file. Clearing the box issues a fast request while a slow one is still in flight, and whichever lands last wins. Debouncing cannot fix that — it changes how many requests are issued, not how they are ordered. Each request now takes a monotonic token and stale answers are dropped, while a current failure still throws, because an error someone is waiting on has to surface.

3 — Widening a filter emptied the list. Found while capturing the screenshots for this entry. The combobox's Backspace produces funders/, deliberately — and _in_domain compared it literally, where funders/ never equals a domain and funders// is never a prefix of one. Every widened filter silently returned zero rows. Two features each correct alone, wrong at the seam, and the seam only exists in the running app.

Its neighbour went with it: corpus_total was counted after the domain filter, so a narrowed list reported "406 of 406" — technically a number, and useless.

Under the hood

The ledger learned to see the frontend

Before this, a spec covered by node --test would have reported MISSING — and the tempting fix for MISSING is deleting the row, which turns a real promise into no promise at all.

app/scripts/run-tests.mjs runs node's own test runner (native type-stripping, zero dependencies) and writes app/.spec-results.json in the shape scripts/spec_status.py already consumes. The ledger merges it and shouts if both suites ever claim one ID.

That runner had a trap worth naming: an unconsumed TestsStream exits 0 having reported nothing. A green build that ran no tests is the worst outcome available here, so it uses for await rather than event listeners.

Mutation testing, and two false greens

Every rule was broken in turn to confirm its test goes red. Two did not, and both were mine:

  • FOCUS-01 searched "literacy" and passed with tag-matching switched off — because the path is strategies/adult-literacy-numeracy/… and search already matched paths. Green, and testing nothing. It now searches strategy:adult, which cannot appear in a path, and asserts as much before running.

  • FOCUS-07's ordering assertion held on date order alone. The cross-tagged source is now the oldest in the fixture, so it can only lead if the tag decided.

What's Next

  • An index for cross-tagged sources. A plain page load narrows on the key, so a source tagged into a strategy it does not live under is only caught once its row is read — which a search does and a page load does not. FOCUS-07 asserts both halves, so the day an index closes it, a test says so.

  • Search costs 5.8 seconds because it opens every file. The same index would fix that too.

  • Multiple focuses. domains: is a list; one chip at a time is what ships, because inventing multi-focus ranking against zero multi-valued data would be designing for an imaginary corpus.

  • context-v/specs/Domain-Navigation.md · Corpus-Tree.md · Strategy-Focus.md

  • ai-labs/context-v/blueprints/Show-The-Filesystem-Of-A-Workspace.md — the cross-app pattern

  • changelog/2026-08-22_05.md — the theme these surfaces are drawn in