/**
* Everything the tree column *decides*.
*
* The column itself is `treeRows` with a different renderer — that is §3's
* whole claim, and this module is what makes it true in the browser: it holds
* the view state the TUI keeps in `ExplorerState`, the reducers that move it,
* and the presentation mapping from a `TreeRow` to something a `
` can
* render. `Tree.tsx` is left with a `useState`, a `map` and four handlers.
*
* That split is not stylistic. §10 forbids a DOM test environment, so a branch
* inside a `.tsx` is a branch no test can reach; every branch the tree needs
* lives here, where an ordinary unit test covers it.
*
* ## Tier rules (§2)
*
* `src/web/client/**`: `src/web/shared` and browser deps only. The view-models
* arrive through `../../shared/view`, the one sanctioned door onto
* `src/core/view` (§2.1) — never from `src/core` directly, even though the
* modules behind the door are proven node-free. This file also touches no DOM
* type at all, which is what lets the root `tsconfig.json` project (no `DOM`
* lib) compile the tests that import it.
*/
import type { TreeRow, TreeState, ViewGraphModel } from "../../shared/view";
import { formatTreeMeta, treeEmptyHint, treeRows } from "../../shared/view";
import type { GraphPayload, WireNodeKind, WireNoteSource } from "../../shared/wire";
import type { IconName } from "../shell/icons.model";
// --- the view state ------------------------------------------------------------
/**
* The tree's own state — core's {@link TreeState}, verbatim.
*
* Deliberately an alias rather than a parallel interface. `treeRows` reads
* `expanded`, `showInternals`, `provFilter` and `query`; a client-side copy
* with the same four fields would be a second declaration of one contract, and
* the first time core added a fifth the browser would silently stop honouring
* it.
*
* It is **not** `src/web/client/state.ts`'s `TreeState`. That one is a P1
* placeholder carrying only an expanded-id list, and it stays where it is:
* this state is owned by the tree column, lives in the component that renders
* it, and never crosses the context bus. 's bus is `selectedId` — which
* rows happen to be open is not something the note column or the graph has any
* business reacting to.
*/
export type TreeViewState = TreeState;
/**
* The provenance filter cycle: all → human → agent → generated → all.
*
* The same order as the TUI's `p` key (`src/pi/viewer/tui/theme.ts`), and
* declared again here because the client tier may not import `src/pi` and the
* order is not part of `src/core/view`. That is a real, if small, duplication
* — so it is one array with a test asserting its contents, rather than an
* `if/else` chain in two places. If a fourth provenance is ever added, the
* honest fix is to promote this into `src/core/view` and take it through the
* door; today it is four literals and a promotion would be ceremony.
*/
export const PROVENANCE_CYCLE: readonly (WireNoteSource | null)[] = [null, "human", "agent", "generated"];
/**
* The tree as it opens: nothing expanded but the roots, no filter.
*
* Roots are expanded eagerly because a tree whose every row is collapsed shows
* two words (`vault`, `repository`) and reads as an empty column. The TUI's
* `initialState` does the same thing for the same reason.
*
* A fresh `Set` per call, so two callers cannot alias — and, more importantly,
* so a reducer that mutated one could not corrupt the default for the next
* mount.
*/
export function initialTreeView(roots: readonly string[] = ["vault", "repository"]): TreeViewState {
return { expanded: new Set(roots), showInternals: false, provFilter: null, query: "" };
}
// --- reassembling the wire payload -------------------------------------------------
/**
* The wire payload as the view-models want it.
*
* `WireGraphModel` is `Omit` — the payload hoists
* that map to its own top-level `dangling` rather than shipping it twice
* (§3) — so exactly one field has to be put back before `treeRows` or
* `detailModel` will accept it. The door (`shared/view.ts`) deliberately does
* not do this: it is a wire concern, and a door that carried a transformation
* would be a second implementation rather than a re-export.
*
* So it is done here, once, and every caller in the client goes through it.
*/
export function viewModel(payload: GraphPayload): ViewGraphModel {
return { ...payload.model, danglingLinks: payload.dangling };
}
/**
* The rows to render, or `[]` before the first graph arrives.
*
* `null` is not an error state — it is the half-second between mount and the
* first `/api/graph` response, and it happens on every single load. Returning
* an empty array rather than throwing keeps the caller a `map`.
*/
export function rowsFor(payload: GraphPayload | null, state: TreeViewState): TreeRow[] {
if (payload === null) return [];
return treeRows(viewModel(payload), state);
}
// --- the draft folder row ------------------------------------------------------------
/**
* The id of the placeholder row a pending "New folder" occupies.
*
* A real id from the same namespace as the rest of the tree, so the row is an
* ordinary `TreeRow` that renders, indents and takes the rename input with no
* special-casing anywhere below {@link withDraftRow}. It cannot collide with a
* folder the vault holds, because `slugify` strips the leading `\u0000`.
*/
export const DRAFT_FOLDER_ID = "vfolder:\u0000draft";
/**
* Where a new folder created from `target` would go.
*
* A right-click on the vault makes a top-level folder; on a folder, a child of
* it; on a *note*, a sibling of that note — which is what "new folder here"
* means when "here" is a file. Returns the parent path, `""` for the root.
*/
export function newFolderParent(target: { type: "note" | "folder" | "vault"; path: string }): string {
if (target.type === "vault") return "";
if (target.type === "folder") return target.path;
return target.path.split("/").slice(0, -1).join("/");
}
/** The path `createFolder` should be asked for, given a parent and a typed name. */
export function newFolderPath(parent: string, name: string): string | null {
const trimmed = name.trim();
if (trimmed === "") return null;
return parent === "" ? trimmed : `${parent}/${trimmed}`;
}
/**
* Splice a draft folder row into the list, under its parent.
*
* This is what replaces the `window.prompt`. Renaming is already inline (click
* a row, type, Enter) and a modal for the adjacent gesture was the odd one
* out — it also blocks the thread, cannot be styled, and on a folder deep in
* the tree gives no clue *where* the folder will land. A draft row shows the
* answer: it sits at the position the real folder will occupy, indented under
* the right parent, with the cursor in it.
*
* Created only after the name is committed, so an abandoned draft leaves
* nothing behind — the alternative (create "untitled", then rename) litters
* the vault on Escape and fires two requests for one gesture.
*
* The draft goes **last among its parent's subtree** rather than in sort
* position, because it has no name yet to sort by and a row that jumps as you
* type is worse than one that appears at a predictable place.
*/
export function withDraftRow(rows: readonly TreeRow[], parent: string | null): TreeRow[] {
if (parent === null) return [...rows];
const draft: TreeRow = {
id: DRAFT_FOLDER_ID,
depth: parent === "" ? 1 : parent.split("/").length + 1,
hasKids: false,
expanded: false,
label: "",
kind: "module",
provenance: null,
meta: null,
};
// The root's draft goes after the whole tree; a folder's goes after the last
// row that is still inside that folder. `treeRows` emits a subtree
// contiguously, so "the last row at greater depth after the parent" is the
// end of it.
if (parent === "") return [...rows, draft];
const at = rows.findIndex((row) => row.id === `vfolder:${parent}`);
if (at === -1) return [...rows, draft];
let end = at + 1;
while (end < rows.length && (rows[end] as TreeRow).depth > (rows[at] as TreeRow).depth) end++;
return [...rows.slice(0, end), draft, ...rows.slice(end)];
}
// --- reducers -----------------------------------------------------------------------
/**
* Open or close a row.
*
* Returns a **new** state with a new `Set`. Mutating the existing one would be
* cheaper and would not re-render: Preact's `useState` bails out on
* `Object.is` equality, so an in-place `expanded.add(id)` produces a correct
* model and a frozen screen. That failure is silent and maddening, which is
* why every reducer here copies.
*/
export function toggleExpanded(state: TreeViewState, id: string): TreeViewState {
const expanded = new Set(state.expanded);
if (!expanded.delete(id)) expanded.add(id);
return { ...state, expanded };
}
/** Force a row open. Idempotent — the right-arrow key's half of the pair. */
export function expand(state: TreeViewState, id: string): TreeViewState {
if (state.expanded.has(id)) return state;
const expanded = new Set(state.expanded);
expanded.add(id);
return { ...state, expanded };
}
/** Force a row closed. Idempotent. */
export function collapse(state: TreeViewState, id: string): TreeViewState {
if (!state.expanded.has(id)) return state;
const expanded = new Set(state.expanded);
expanded.delete(id);
return { ...state, expanded };
}
/** Folder represented by a drop target: `null` is the vault root. */
export function dropFolder(id: string): string | null | undefined {
if (id === "vault") return null;
return id.startsWith("vfolder:") ? id.slice("vfolder:".length) : undefined;
}
export function mutableTreeRow(id: string): { type: "note" | "folder" | "vault"; path: string } | null {
if (id === "vault") return { type: "vault", path: "" };
if (id.startsWith("note:")) return { type: "note", path: id.slice("note:".length) };
if (id.startsWith("vfolder:")) return { type: "folder", path: id.slice("vfolder:".length) };
return null;
}
export function deletesSelection(selectedId: string | null, target: { type: "note" | "folder" | "vault"; path: string }): boolean {
if (selectedId === null) return false;
if (target.type === "note") return selectedId === `note:${target.path}`;
if (target.type === "folder") return selectedId === `vfolder:${target.path}` || selectedId.startsWith(`vfolder:${target.path}/`) || selectedId.startsWith(`note:${target.path}/`);
return false;
}
/**
* Notes delete directly; folders ask once, **in the menu**.
*
* Not a `window.confirm` — no gesture in this workspace opens a browser
* dialog any more. But not nothing either: `deleteFolder` is
* `fs.rm(recursive: true)` on a directory that may hold hundreds of notes,
* there is no undo, and the tree row shows no count, so a single stray click
* on a 300-note folder is unrecoverable. A note is one file the user can see
* the name of, which is why §#37 dropped its confirmation and this keeps one.
*
* The confirmation is {@link deleteItemLabel}: the same menu item, clicked
* twice, with the second click's label saying exactly what it will destroy.
*/
export function deleteNeedsConfirmation(target: { type: "note" | "folder" | "vault" }): boolean {
return target.type === "folder";
}
/**
* The delete menu item's label — what pressing it *now* will do.
*
* One function so the label and the action cannot disagree: `Tree.tsx` reads
* the same `armed` flag to decide whether the next click deletes, and a menu
* item that says "Delete" but arms instead (or the reverse) is the classic
* version of this bug. Naming the folder in the armed state is the whole
* safety mechanism — it is the user's chance to notice they right-clicked the
* wrong row.
*/
export function deleteItemLabel(target: { type: "note" | "folder" | "vault" }, label: string, armed: boolean): string {
if (!deleteNeedsConfirmation(target)) return "Delete";
return armed ? `Delete “${label}” and everything in it` : "Delete…";
}
/**
* Set the substring filter.
*
* No debounce and no minimum length: `treeRows` is a synchronous walk over an
* in-memory model of a few hundred nodes, so filtering on every keystroke is
* cheaper than the timer that would avoid it, and a filter that lags behind
* the text box is the single most irritating thing a filter can do.
*/
export function setQuery(state: TreeViewState, query: string): TreeViewState {
return state.query === query ? state : { ...state, query };
}
/** Advance the provenance filter one step around {@link PROVENANCE_CYCLE}. */
export function cycleProvenance(state: TreeViewState): TreeViewState {
const at = PROVENANCE_CYCLE.indexOf(state.provFilter);
// `indexOf` returns -1 for a value outside the cycle, and `(-1 + 1) % 4` is
// 0 — which lands on "all". A state that somehow held an unknown filter
// therefore recovers on the next press instead of sticking.
const next = PROVENANCE_CYCLE[(at + 1) % PROVENANCE_CYCLE.length] ?? null;
return { ...state, provFilter: next };
}
/** Show or hide repo plumbing (gitState / external / package / entryPoint). */
export function toggleInternals(state: TreeViewState): TreeViewState {
return { ...state, showInternals: !state.showInternals };
}
// --- keyboard navigation --------------------------------------------------------------
/** What a key produced: the next state, and the row that should be selected. */
export interface TreeKeyResult {
readonly state: TreeViewState;
readonly selectedId: string | null;
/** `false` when the key meant nothing here and the browser should keep it. */
readonly handled: boolean;
}
/**
* The index of `id` among the currently visible rows, or `-1`.
*
* Rows are the flattened, filtered, expansion-resolved list — so "the next
* row" is genuinely the next thing on screen, not the next sibling in the
* graph. Deriving the cursor from the id rather than storing an index is the
* same choice the TUI made (`ExplorerState.selectedId` is the source of truth)
* and for the same reason: an index goes stale the moment a filter changes the
* row set, and goes stale *plausibly*, pointing at a real but wrong row.
*/
export function indexOfRow(rows: readonly TreeRow[], id: string | null): number {
if (id === null) return -1;
return rows.findIndex((row) => row.id === id);
}
/**
* The id at a row index, or `null` when the index is outside the list.
*
* Exported and used by every caller below rather than each writing
* `rows[i]?.id ?? null` inline, and the reason is coverage rather than
* brevity. `noUncheckedIndexedAccess` makes an index access `T | undefined`
* even where the surrounding arithmetic has already clamped it into range, so
* an inline `?? null` is a branch that *cannot* be taken and therefore cannot
* be covered — a permanent hole in a gate that is supposed to mean something.
* Funnelling those accesses through one function turns the same check into a
* branch that is genuinely reachable, because an out-of-range index is a legal
* argument here and is tested as one.
*/
export function idAt(rows: readonly TreeRow[], index: number): string | null {
return rows[index]?.id ?? null;
}
/**
* Move the cursor `delta` rows, clamped to the ends.
*
* Clamped rather than wrapped: wrapping from the last note back to `vault` is
* disorienting in a tree, where position carries meaning. With nothing
* selected, a downward move starts at the top and an upward move at the
* bottom, so the first arrow key after a fresh load always lands somewhere.
*/
export function moveSelection(rows: readonly TreeRow[], id: string | null, delta: number): string | null {
if (rows.length === 0) return null;
const at = indexOfRow(rows, id);
const from = at === -1 ? (delta > 0 ? -1 : rows.length) : at;
return idAt(rows, Math.min(rows.length - 1, Math.max(0, from + delta)));
}
/**
* The id of the row that visually contains `id` — the row above it with a
* smaller depth.
*
* Used by the left arrow on an already-collapsed row, which is the one tree
* gesture users expect and nobody implements. Scanning upwards for the first
* shallower row is exact for a flattened tree: whatever that row is, it is the
* parent, because every row between them is a descendant of it.
*
* Written as a reversed `slice` rather than a descending index loop for the
* reason {@link idAt} exists: iterating yields a `TreeRow`, where indexing
* yields `TreeRow | undefined` and forces an unreachable guard. The copy is of
* the rows above the cursor, which is bounded by what is on screen.
*/
export function parentOf(rows: readonly TreeRow[], id: string): string | null {
const at = indexOfRow(rows, id);
// `at <= 0` covers both "not visible" (-1) and "the first row, which has
// nothing above it" (0), and in either case there is no parent to find.
const self = at <= 0 ? undefined : rows[at];
if (self === undefined) return null;
for (const row of rows.slice(0, at).reverse()) {
if (row.depth < self.depth) return row.id;
}
return null;
}
/**
* Vim-ish aliases: `j` is `ArrowDown`, `k` is `ArrowUp` ( P4).
*
* A *normalizer* rather than two more branches in {@link treeKey}, because
* the aliasing and the navigation are separate concerns and folding them
* together would double the arrow tests. Everything below `treeKey`'s first
* line then works in terms of arrows only.
*
* ## Why the aliases are tree-scoped and not global
*
* says "vim-ish `j/k` **in the tree**", and the qualifier is load-bearing
* on both sides. A global `j` would move the tree's cursor while the user is
* reading the note column — an invisible change to a surface they are not
* looking at — and it would make `j` untypeable in the graph's depth control.
* Scoping it here means the tree's own `onKeyDown` applies it, which is only
* reached when focus is genuinely inside the tree.
*
* Case is preserved deliberately: `J` and `K` are Shift-modified keys that
* vim itself binds to something else, so they are left alone rather than
* folded onto their lowercase forms.
*/
export const VIM_KEYS: Readonly> = { j: "ArrowDown", k: "ArrowUp" };
/** Resolve a vim alias, or return the key unchanged. */
export function normalizeTreeKey(key: string): string {
return VIM_KEYS[key] ?? key;
}
/**
* Apply a key to the tree.
*
* `ArrowDown`/`ArrowUp` move, `Home`/`End` jump, `ArrowRight` opens (or
* descends into an already-open row), `ArrowLeft` closes (or climbs to the
* parent). That last pair is the whole reason this is a function rather than a
* switch in the component: each arrow has two behaviours depending on the
* row's state, which is four branches the coverage gate would never see inside
* a `.tsx`.
*
* `handled: false` for everything else, so the caller knows not to
* `preventDefault` a key it did not consume — swallowing Tab would trap
* keyboard users in the column, which is the accessibility bug P4 is meant to
* be fixing rather than introducing.
*
* `typing` closes the one path the first version missed: the filter box lives
* inside the listened element, so typing `j` into a query like "jack" arrived
* here as an alias and moved the cursor instead of entering the character.
* The caller passes whether the event's target is a text field (the same
* `isTextEntry` question the global map asks), and a keystroke that belongs
* to the input is refused here — the decision in the model, not a DOM check
* in the `.tsx`.
*/
export function treeKey(
rows: readonly TreeRow[],
state: TreeViewState,
selectedId: string | null,
rawKey: string,
typing = false,
): TreeKeyResult {
const unchanged = { state, selectedId, handled: false } as const;
if (typing) return unchanged;
const moved = (id: string | null): TreeKeyResult => ({ state, selectedId: id, handled: true });
const key = normalizeTreeKey(rawKey);
if (key === "ArrowDown") return moved(moveSelection(rows, selectedId, 1));
if (key === "ArrowUp") return moved(moveSelection(rows, selectedId, -1));
if (key === "Home") return moved(idAt(rows, 0));
if (key === "End") return moved(idAt(rows, rows.length - 1));
if (key !== "ArrowRight" && key !== "ArrowLeft") return unchanged;
const at = indexOfRow(rows, selectedId);
const row = at === -1 ? undefined : rows[at];
if (row === undefined || selectedId === null) return unchanged;
if (key === "ArrowRight") {
// Closed and has children → open it. Already open → step onto the first
// child, which is the next row by construction. A leaf does nothing.
if (!row.hasKids) return unchanged;
if (!row.expanded) return { state: expand(state, selectedId), selectedId, handled: true };
return moved(moveSelection(rows, selectedId, 1));
}
// ArrowLeft: open → close it; closed (or a leaf) → climb to the parent.
if (row.hasKids && row.expanded) return { state: collapse(state, selectedId), selectedId, handled: true };
const parent = parentOf(rows, selectedId);
return parent === null ? unchanged : moved(parent);
}
// --- presentation ---------------------------------------------------------------------
/**
* Kind → icon.
*
* The descendant of the old `KIND_GLYPHS` text table: the same nine-way
* distinction (a parallel table to the TUI's `kindStyle`, which the client
* tier may not import), but drawn from {@link ICONS}' one sprite instead of
* nine unrelated font fallbacks. The TUI-cross-training argument for reusing
* the *characters* is gone now that neither surface draws them as text, but
* the pairings survive: `vault` solid against `gitState` hollow, `note` with
* its folded corner against `file` without, `external` still pointing out.
*
* Colour is *not* duplicated — that stays in the stylesheet, keyed by
* {@link TreeRowView.kind}.
*/
const KIND_ICONS: Readonly> = {
vault: "vault",
note: "note",
repository: "repository",
module: "module",
package: "package",
entryPoint: "entryPoint",
gitState: "gitState",
external: "external",
file: "file",
};
/** The icon name for a node kind. */
export function kindIcon(kind: WireNodeKind): IconName {
return KIND_ICONS[kind];
}
/**
* Provenance → glyph.
*
* `●` human, `◐` agent, `○` generated — the TUI's vocabulary again, and the
* one place AGENTS.md rule 4 shows up in the browser: agent-written content
* must never look human-authored, so the marker is a filled/half/hollow shape
* that survives greyscale, colour-blindness and a screenshot, rather than a
* colour alone. Structural nodes have no provenance and get nothing.
*/
const PROVENANCE_GLYPHS: Readonly> = {
human: "●",
agent: "◐",
generated: "○",
};
/** The provenance marker for a row, or `""` for a structural node. */
export function provenanceGlyph(provenance: WireNoteSource | null): string {
return provenance === null ? "" : PROVENANCE_GLYPHS[provenance];
}
/** The `title=` on the provenance marker. Spells out what the shape means. */
export function provenanceTitle(provenance: WireNoteSource | null): string {
return provenance === null ? "" : `${provenance}-authored`;
}
/** The disclosure state of a row: which way the twisty points, or none. */
export type Twisty = "open" | "closed" | "leaf";
// --- ARIA ---------------------------------------------------------------------------
/**
* The `id` attribute for a tree row.
*
* Needed because focus lives on the `
`, not on the rows — a
* roving `tabindex` would put every row in the Tab order and make Tab a
* fourth way to walk the tree. The standard alternative is
* `aria-activedescendant`, which is an **id reference**, so the rows need ids.
*
* Derived from the row id with everything outside `[A-Za-z0-9_-]` replaced,
* for `rowDomId`'s reason in `search.model.ts`: a graph id is
* `module:src/web/client`, which is a legal HTML `id` and an illegal CSS
* selector, and an assistive technology that builds a selector from the
* reference gets nothing. The replacement is injective enough for this — two
* ids differing only in punctuation would collide, and the graph builder
* derives ids from slugs and paths, where `:` is always the kind separator.
*/
export function rowDomId(id: string): string {
return `weave-row-${id.replace(/[^A-Za-z0-9_-]/g, "-")}`;
}
/** `aria-posinset` / `aria-setsize` for one row. */
export interface TreePosition {
readonly posinset: number;
readonly setsize: number;
}
/**
* Position-in-set for every row, from the flattened list.
*
* A `role="tree"` whose items are not nested in `role="group"` elements is
* the *flat* tree pattern, and it is the right one here — the rows come from
* core's `treeRows` already flattened, and rebuilding a nested `
` in the
* browser would be a second tree structure to keep in agreement with the
* TUI's. The pattern's cost is that `aria-level` alone tells a screen reader
* the depth and nothing about extent: "level 2" with no "3 of 7" leaves a
* user unable to tell a short list from a long one without walking it, which
* is precisely the orientation a tree is for.
*
* ## Why a stack rather than a contiguous run
*
* The obvious implementation — "siblings are consecutive rows at the same
* depth" — is wrong for exactly the shape a tree always has. Given
*
* ```text
* vault depth 0
* Graph model depth 1
* Viewer depth 1
* repository depth 0
* ```
*
* `vault` and `repository` are siblings, and they are *not* consecutive: two
* children sit between them. A run-based pass reports both as "1 of 1", which
* is a confidently wrong announcement rather than a missing one. So the open
* set at each depth is kept on a stack, and a row at depth *d* closes every
* set deeper than *d* — the standard flattened-tree walk, and the only one
* that gets the roots right.
*
* One pass over the list, because the per-row version is O(rows²) on a
* repository with a few thousand files.
*/
export function treePositions(rows: readonly TreeRow[]): TreePosition[] {
const out: TreePosition[] = rows.map(() => ({ posinset: 1, setsize: 1 }));
/** Indices of the still-open sibling set at each depth. */
const open: number[][] = [];
/**
* Close every set deeper than `depth` and write its members' positions.
*
* `splice` rather than a `pop` loop, and that is a coverage decision as
* much as a brevity one: `open.pop()` is `number[] | undefined` under
* `noUncheckedIndexedAccess` even where the loop guard has already proved
* the array non-empty, so the `?? []` it needs is a branch that *cannot*
* be taken and therefore cannot be covered — a permanent hole in a gate
* that is supposed to mean something. `splice` returns an array of arrays
* with no such element. The same reasoning as {@link idAt}'s.
*/
const closeBelow = (depth: number): void => {
for (const set of open.splice(depth + 1)) {
for (const [index, at] of set.entries()) out[at] = { posinset: index + 1, setsize: set.length };
}
};
for (const [index, row] of rows.entries()) {
closeBelow(row.depth);
// A gap in depth cannot happen from `treeRows` (a child is always exactly
// one deeper than its parent), but a truncated payload could produce one,
// and a missing slot would silently drop the row from every set. Filling
// up to the depth and then reading the *last* slot keeps the access
// total: `at(-1)` on an array just proved non-empty still needs a guard
// under `noUncheckedIndexedAccess`, so the set is pushed and reused
// instead of re-indexed.
let set = open[row.depth];
if (set === undefined) {
set = [];
while (open.length < row.depth) open.push([]);
open.push(set);
}
set.push(index);
}
closeBelow(-1);
return out;
}
/** A `TreeRow`, resolved to the strings and flags a list item renders. */
export interface TreeRowView {
readonly id: string;
/** The `id` attribute, for `aria-activedescendant`. */
readonly domId: string;
readonly depth: number;
readonly label: string;
readonly kind: WireNodeKind;
/** Which sprite glyph the kind slot draws; the `.tsx` builds the `