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.
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."

What's New?
A domain combobox. Type any near-match —
Ascendium Educationfindsfunders/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 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"

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 → 4The 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:
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-01searched"literacy"and passed with tag-matching switched off — because the path isstrategies/adult-literacy-numeracy/…and search already matched paths. Green, and testing nothing. It now searchesstrategy: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-07asserts 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.
Related
context-v/specs/Domain-Navigation.md·Corpus-Tree.md·Strategy-Focus.mdai-labs/context-v/blueprints/Show-The-Filesystem-Of-A-Workspace.md— the cross-app patternchangelog/2026-08-22_05.md— the theme these surfaces are drawn in
