The tree
The site's own routes as a browsable file tree, with counts that come from the same sources the pages do.
- used on
- depends on
- motion@phosphor-icons/react
A file tree of the site itself. Folders open and close and files link to the real routes. The numbers beside them come from the same places the pages read, so the tree cannot claim twelve posts when there are fourteen.
Hand-written, not derived
Walking the App Router to build this automatically sounds obviously right until you ask it
questions it cannot answer. Should /admin appear? What order do the branches go in? Does
blog/[slug] become a folder or disappear? Every one of those is an editorial call. A derived
tree would need a config file to override them, which is the hand-written tree again, plus a
walker to maintain.
So the shape is a typed array in lib/site-tree.ts and the numbers arrive at render. The tree
going stale means a page was added and nobody added the node, and that is a one-line fix in a
file that sits next to the component.
Counts are a state, not a number
logCount is number | null, and the difference matters. Zero entries is a real count and
gets shown. A database that did not answer has no number to report, and the row renders without
one rather than asserting a zero nobody measured.
buildSiteTree({ posts, projects, logCount: logEntries?.length ?? null })The counts come off lists the page already has in memory, so the tree costs no extra query.
Three frames, and the one that was missing
The tree is not static, even though the structure is. It streams inside a <Suspense> on the
home page beside the profile card, because both need the log count and the count comes from
Postgres. So there is a real moment where the card is a skeleton. That skeleton has been on the
site since the day the home page was split into slots.
TreeCardSkeleton is that frame, and it is the card. The same .bento-card, the same 32px rows
at the same indents, the same footer. Even the N routes meta is real: siteTreeRouteCount() is
arithmetic over a hand-written constant, so it is known without a query. Only the words are grey.
/components did not know that. It listed two states, ok and empty, and the third one was
shipping in production the whole time. The registry is the only place that claim lives, so a
wrong entry there is not caught by tsc or by any test. It is caught by someone opening the
page, noticing the tree has no loading frame, and remembering that it does.
empty is a fresh fork of this template: no posts, no projects, and no DATABASE_URL. Every
branch is still there, because the branches are hand-written, and every number is gone.
The animation
Folders expand with a height transition and their children stagger in. The stagger is dropped
past STAGGER_LIMIT children. A delay multiplied across a long list stops reading as
choreography and starts reading as lag.
Closing is faster than opening (160ms against 220ms). A folder you just closed should not make you wait for a decision you have already made.
The reduced-motion branch is written out by hand rather than relying on the global CSS reset.
Motion animates through JavaScript and walks straight past that block. So the component asks
useReducedMotion() itself and returns the same state changes with no transition.