# 8-bit FM

A read-only file and git repository explorer that draws your repo as an explorable 8-bit map — and is fully keyboard and screen-reader accessible by construction.

Inspiration

Every file manager is a list. When you join an unfamiliar project, a list of two hundred directories tells you almost nothing about what matters, where the weight of the codebase sits, or what's changed recently.

Plenty of people have tried to fix that with visualization. The problem is that almost every one of those attempts makes file browsing worse for anyone using a screen reader. A <canvas> element is one opaque rectangle to assistive technology. Pixels carry no meaning. So the usual outcome is a tool that's prettier for some users and unusable for others.

We wanted to build one that was both, and we wanted the accessibility to be the foundation rather than a retrofit.

What it does

8-bit FM reads a directory tree and renders it two ways at once:

  • A pixel-art map you walk through. Top-level directories become buildings, sized and placed from real repository structure. A status line reports live git state — branch, uncommitted changes, folder count.
  • A semantic ARIA tree — a real role="tree" with full keyboard navigation and screen-reader announcements.

Both read their selection from one store. Click a building and the tree follows; arrow through the tree and the map follows. There is no second copy of the selection or of the tree data anywhere, so the two views cannot drift apart.

The important part: the canvas is aria-hidden="true". It's decorative. The ARIA tree is the actual user interface.

How we built it

Tauri v2 for the shell — a Rust backend with a React 19 + TypeScript frontend in a native webview. Three developers, three lanes: Rust backend, canvas scene, accessibility and state.

One contract, frozen on day one

Before writing code we wrote a data contract and froze it: node shape, path rules, sorting, the git_status vocabulary. Then we built a 35-node mock fixture as the executable version of that contract, and agreed that if the document and the fixture ever disagreed, the fixture wins.

That fixture is what let three people work in parallel without blocking each other. Each lane coded against the same schema from hour two.

Two renderers over one state tree

The store is plain TypeScript — no React, no DOM, no fetch. Both renderers bind to it with useSyncExternalStore. Neither owns state; they dispatch intents back (select(path), toggle(path)) and re-read.

One rule holds it together: every state change produces new object identities. Mutating the expansion Set in place keeps the reference equal, React sees no change, and nothing re-renders — a bug that looks like a broken function rather than a broken store. So every change copies:

const expanded = new Set(snapshot.expanded)   // new Set
snapshot = { ...snapshot, expanded }          // new snapshot

Flattening the tree before rendering

The single best decision we made. Rather than recursing over the tree in JSX, we flatten the visible nodes into an array of { node, level, posinset, setsize } in depth-first pre-order.

That turns every keyboard case into array arithmetic. Down is $i + 1$, Up is $i - 1$, Home is $0$, End is $n - 1$. And "go to parent" — which normally needs parent pointers or a second data structure — becomes a backward scan, because in pre-order a node's parent is the nearest preceding row at a shallower depth:

$$ \text{parent}(i) = \max {\, p < i \;:\; \text{level}(p) < \text{level}(i) \,} $$

Ten lines, $O(d)$ in practice where $d$ is the distance to the parent. No pointers, no bookkeeping, nothing to keep in sync.

Letting the compiler enforce the contract

FileNode is a discriminated union rather than one type with optional fields:

type FileNode =
  | { kind: 'dir';  name: string; path: string; file_count: number; children: FileNode[] }
  | { kind: 'file'; name: string; path: string }

The contract says kind is the discriminator, never the presence of children. With the union, TypeScript refuses to let you touch node.children until you've narrowed on kind. A rule you'd otherwise have to remember at 3 a.m. becomes something the typechecker won't let you break.

Validating across the process boundary

Data arriving from Rust is typed unknown, not asserted. There is no as RepoTree anywhere in the codebase. Rust serializes fileCount; our contract says file_count. Rust sends children on files; our types say only directories have them. One adapter is the single place those disagree, and it throws a named error instead of building a half-valid tree that explodes three renders later.

Challenges we ran into

The live region that never spoke

Screen readers announce a live region when its text mutates. Announce the same message twice and the second is silent, because the DOM didn't change. The obvious fix is a React key that forces a fresh element.

That fix is wrong, and subtly so. A keyed element gets unmounted and remounted, so it arrives in the DOM already populated — and screen readers watch existing regions for mutations. A region that appears pre-filled usually isn't announced at all. We'd built a live region that was silent in every case.

The working version keeps the element stable and varies the text instead, with a trailing non-breaking space on odd counter values. It forces a diff and isn't spoken.

Double announcements

Our first assumption was that if focus doesn't move, a live region can't collide with anything. Half right. Focus not moving prevents the row being re-read — it does not stop the screen reader announcing an aria-expanded state change on the element that already has focus.

So expanding a folder said "expanded" twice, from two independent sources. The fix was to stop competing: strip the state word and let the announcement carry only what ARIA can't — the child count. Collapse lost its announcement entirely, because once you remove the state word and the name, nothing is left to say.

The tree stole focus on page load

Selection starts null, so the focus fallback landed on row zero and the effect fired on first render — pulling focus into the tree before the user had touched anything. A screen-reader user would land mid-page and never hear the heading. A ref that skips the first run fixed it, but nothing in our test suite would ever have caught it. We only found it by reasoning about the load sequence out loud.

Announcements that were true but misleading

The row's accessible name reports file_count, which is recursive — 25 for the repository root. The expansion announcement reported children.length, which is immediate — 8 for the same folder. Both numbers correct, spoken seconds apart, about the same directory. We renamed the second to "immediate entries" so the noun carries the difference instead of the numbers appearing to contradict each other.

And an empty directory announced "expanded, 0 items" while its row deliberately carried no aria-expanded attribute at all — the live region contradicting the DOM, and toggle() quietly accumulating a path that would sit inert forever.

Git, mostly

We lost more time to repository hygiene than to any single feature. A fixture tracked at zero bytes through several commits. IDE state committed from a teammate's machine, producing merge conflicts on a SQLite file that GitHub's web editor refuses to open. Rust source landing in the frontend directory four separate times. Two forks merging back and forth instead of one canonical remote.

The lesson that actually stuck: work that exists in a conversation is not work that exists. Twice we discovered that a decision everyone was following had never been written to a file, let alone committed.

A network failure that looked like a dependency failure

cargo died reporting it couldn't fetch a macOS-only crate on a Windows machine. The crate was a red herring — cargo reads index metadata for every platform to resolve the graph. The real cause was HTTP/2 multiplexing: cargo opens around a hundred concurrent streams over one TLS connection, and the network handled one fine but collapsed under that many. Forcing HTTP/1.1 fixed it. The partially populated index directory was what gave it away.

What we learned

Invert the usual order. Building the semantic tree first and treating the canvas as decoration meant "delete the map and you still have a working file browser" was true by construction rather than by luck. It was also our safety net: if the canvas had broken late, we still had a demo.

Freeze the contract before writing code. Three people, one schema, zero blocking. The mock fixture as the executable contract was worth more than any individual feature we built.

Verified beats reasoned. Every one of the accessibility bugs above passed structural checks. The ARIA attributes were valid. Exactly one element was focusable. The typechecker was clean. None of that told us the app was silent.

Let the type system hold the invariants. The discriminated union caught real mistakes. noUncheckedIndexedAccess forced us to handle every keyboard boundary explicitly instead of producing undefined at runtime, and once flagged a whole keyboard case we'd left unimplemented.

What's next

  • Finish the scanner. The Rust walker needs the contract's sort order, forward slash normalization, and a depth cap. Git status is currently wired for the fixture only.
  • Live refresh. File-system watching so the map updates as you work.
  • File-system health monitoring. The same architecture fits it directly — replace file counts with capacity metrics and git status with alert severity, and the spatial view becomes a dashboard. An accessible monitoring dashboard is something we haven't seen anywhere.
  • More themes. The map is one renderer among many possible ones, and the contract doesn't care which.

Built with

Rust · Tauri v2 · React 19 · TypeScript 6 · Vite 8 · Canvas · WAI-ARIA

What it does

How we built it

Challenges we ran into

Accomplishments that we're proud of

What we learned

What's next for File Mon

Built With

Share this project:

Updates

Submission history