---
name: docujoint-view-composition
description: Compose docujoint UIs from the component catalogue — discover the vocabulary with `dj catalog`, arrange composed views out of widgets, and shape how a block's rows draw (layout, card roles, column styles, explorers, facet filters, power search). Also covers `for_each:` view templates — one board per document. Use when designing or changing what a dj dashboard SHOWS.
---

# Composing a view from the catalogue

A docujoint UI is **composed by a definition, not coded**. `format.yaml` says how a
block draws; `dashboard.yaml` says what a page contains and in what order. The
renderer supplies the components — a definition **picks** from the catalogue, it
can never inject one. That is what makes a definition written by someone else
safe to accept and serve (ADR 0009), and it is why the vocabulary is
discoverable rather than guessable:

```sh
dj catalog            # 9 groups, every composable name, where it is declared
dj catalog --json     # the same, machine-readable — read this before composing
```

**Read the catalogue first.** If a name is not in it, the engine does not have
it; inventing `widget: carousel` is a definition error, not a feature request
the renderer will honour.

## Composed views — an ordered list of widgets

A `views:` entry is a page an agent lays out. This is the whole A2UI surface:
the difference between picking a page from a fixed menu and composing one.

```yaml
views:
  - id: triage
    label: Triage
    widgets:
      - { widget: note, text: What needs a human decision, newest surface first. }
      - { widget: stat-row, indicators: [open-anomalies, open-doubts] }
      - { widget: rows, label: Anomalies, from: anomalies,
          filter: { by: Status, label: Status, default: open } }
      - { widget: rows, label: Open questions, from: open-questions,
          search: { by: [Status, About, document] } }

navigation:
  - group: Overview
    items:
      - { label: Triage, view: triage }        # a composed view is a first-class view id
```

Widgets: `stat-row` · `coverage` · `reverse-gap` · `concepts` · `rows` ·
`grouped` · `graph` · `ref-map` · `note` · `search` · `tabs` · `chart` ·
`board` · `timeline` · `record` · `mentions` · `comments` · `activity` · `reactions` ·
`attachments` · `approvals` · `presence`. Each reuses the renderer its
equivalent navigation view already uses, so composing costs no new drawing
code — only the arrangement is new. The same widget may appear many times with
different queries.

**Plugin widgets.** `comments` is the dj/comments plugin's surface — the
comment thread on the page's subject. The kind is always in the catalogue
(vocabulary is global), but it renders only where format.yaml installs the
owner: `plugins: { dj/comments: {} }`. Placed without the install line it
degrades to a note carrying that hint (`plugin-widget-uninstalled`, a
warning — never a load error). The installed reference spelling
`views: [dj/comments]` resolves to the same widget. Uninstalling never
deletes: removing the line hides the surface, the host keeps every stored
thread. `activity` is the dj/activity plugin's surface under the same rules
(`plugins: { dj/activity: {} }`, reference spelling `views: [dj/activity]`):
the merged activity trail of the page's subject — canonical commits,
operational events and conversation as one newest-first list, each item
labeled with its tier. The plugin stores NOTHING of its own — every source it
reads (commits, the operational log, comments, reactions) already belongs to
somebody else, which is what its `mixed` tier means.

`reactions` is the dj/reactions plugin's surface (`plugins: { dj/reactions: {} }`,
reference spelling `views: [dj/reactions]`): the reaction bar on the page's
subject. The vocabulary is the ENGINE'S and closed — 👍 👎 🎉 ❤️ 🚀 👀 😄 😕 —
and a format names none of those words: they mean the same thing in every
knowledge base, which is exactly what a format's own vocabulary never does.
One reaction per (target, emoji, person), so reacting twice is one presence and
taking it back removes only your own.

`attachments` is the dj/attachments plugin's surface
(`plugins: { dj/attachments: {} }`, reference spelling `views: [dj/attachments]`):
the files beside the page's subject — name, size, who attached it, and a
download. Bytes live in the host's object store and never in git, so a clone
stays a clone; removal is soft and credits no storage back. A file that has
EARNED a place in the repository gets there through a commit, not through this
panel.

`approvals` is the dj/approvals plugin's surface
(`plugins: { dj/approvals: {} }`, reference spelling `views: [dj/approvals]`):
sign-off on the page's subject — who was asked, what they said, and (where you
are the reviewer) the buttons to say it. The plugin owns NO tables and NO
doors: an approval is a RECORD in the working layer, written through the same
declared-form door every other operational write uses, so attribution, the
event trail, snapshot/restore, `dj pull` and archive all come free. Installing
it also requires declaring which of YOUR blocks are approvable —
`relations: { approval-subject: { direction: directed, inverse: approvals, to:
[issues] } }` — and it pairs with `work: { approved_when: all | any }` to make
sign-off part of what *closed* means for a block.

`presence` is the dj/presence plugin's surface
(`plugins: { dj/presence: {} }`, reference spelling `views: [dj/presence]`):
who else has the page's subject open right now. It stores nothing anywhere —
the roster is the set of open sockets on the host's own rows channel — and the
handles are self-reported, so they render in a deliberately distinct muted
style and never as resolved person chips. No heartbeat: the socket's lifetime
is the signal.

All six plugin widgets have a live mount as a RECORD-VIEW SECTION
(`- view: dj/comments`, `- view: dj/reactions`, …), where the open record is
the target; placed bare on a dashboard they name no subject and render the slot
sentence instead.

**Mentions (back-references).** A `kind: mentions` view file lists every place
a person is mentioned — prose `@handles`, cell `@handles`, person-column
values — each entry a chip · context line · source badge · link to its owner
(the record's `#/r/` address, or the document). `where:` filters with the one
dashboard query grammar over exactly seven fields: `handle` · `source` ·
`section` · `block` · `row` · `uuid` · `column`. `@me` (bare or quoted) means
the signed-in reader and resolves only client-side from the host directory —
with no viewer the view shows a sign-in empty state, never everyone's
mentions. Inside a record view's `sections:`, a mentions view with
`of: subject` narrows to the open record's own mentions; `of: subject`
anywhere else is refused (`view-mentions-of-outside-record`, a warning — the
view degrades to a note, like every `view-mentions-*` finding).

```yaml
# views/my-mentions.yaml
view:
  kind: mentions
  label: Mentions of me
  where: handle == @me
```

A widget inside a composed view keeps its own state: two `rows` widgets on one
page filter independently.

**`search`** embeds a live search box in any view, scoped by the prefilters the
definition declares — `block`, `state`, `type`, and `contains` (a literal the
row must hold, which is how a person's board scopes to their own rows: an Owner
cell holds a link to `{path}`).

```yaml
      - { widget: search, label: Search their work, block: features,
          contains: "{path}", placeholder: "Search {title}'s features…", limit: 20 }
```

It renders as a **button**, not a second search engine: clicking it opens the
one corpus-search modal with those prefilters already applied, so every place a
reader can search behaves identically and only the starting scope differs. A
scope the modal's own selects cannot express — `contains`, a literal the row
must hold — shows there as a chip they can drop to widen the search.

It has two backends and one behaviour. Against a host that answers named
queries (`dj dashboard`) it is FTS5, and semantic or hybrid where an embedder
is configured — ranked, with excerpts, complete even in split mode where the
payload carries only an index. Against the self-contained artifact it scans the
rows the payload already holds. Same result shape either way, so a view never
declares which one it wants. A prefilter naming a block or type the format does
not declare is a load error, not an empty result set.

The sidebar's own box does the same corpus-wide search alongside its usual job
of narrowing the current view.

Where the operator has named an embedder (`dj dashboard --embed-url`), the box
also offers **Keyword · Semantic · Hybrid** — the same prefilters apply to all
three, and hybrid fuses the two rankings. The engine never calls a model: the
vectors are a host-supplied input, so a host without one simply does not show
the switch.

**`tabs`** is a container: it shows ONE of its widgets at a time, and each tab
carries that widget's own count, so choosing is an informed click rather than a
guess. Use it when the page has several equally-important tables and stacking
them would bury the last one.

```yaml
      - widget: tabs
        tabs:
          - { widget: rows, label: Features,  from: features,       where: "…", search: { by: [state] } }
          - { widget: rows, label: Questions, from: open-questions, where: "…" }
          - { widget: rows, label: Anomalies, from: anomalies,      where: "…" }
```

One level only — tabs inside tabs is a layout nobody can navigate, and the
loader refuses it. A tabbed widget keeps its own controls: the filter belongs
to the panel, not to the bar.

**`chart`** draws an aggregated chart over block rows. `from:`/`where:` select
rows exactly as a `rows` widget does; `x.by` buckets them by a property — a
column of the block, or the derived `state`, the `document`, or the concept
`type` (the same property resolution power search uses); each series draws a
mark over one number per bucket: a row `count` (the default), or the SUM of a
numeric column named by `y:`. A series may carry its own `where:`, so one
chart can compare slices of the same rows. Aggregation happens at build time —
the payload carries categories and plain numbers, and the page never
re-queries. `reference:` draws a horizontal reference line at a value.

```yaml
      - { widget: chart, label: Features by state, from: features,
          x: { by: state }, series: [ { mark: bar, label: features } ] }

      - widget: chart
        label: Estimate vs done, per owner
        from: tasks
        x: { by: Owner }
        series:
          - { mark: bar,  y: Estimate, label: estimated }
          - { mark: line, y: Estimate, where: "state == done", label: done }
        reference: 40
```

Rows marks are `bar · line · area · dot`. An unknown block, x/y column or mark
is a definition error at load, never an empty chart at runtime.

### The second source — `history:` and the marks over it

A rows chart buckets records by what they **say now**. `history:` switches the
same widget to the operational **event log**, so it buckets them by what they
**said then** — and brings four more marks with it: `burndown · burnup · flow
· cycle-time`.

The engine has no state vocabulary of its own here, and that is the point.
`of:` names a column of the charted block **that declares an enum**; `open:`,
`done:` and `cancelled:` name values **of that enum**, and the three groups
must be disjoint. Everything the marks compute is arithmetic over the groups
you declared, so a format whose records move through `drafting · with editor ·
typeset` gets exactly the same four pictures by naming exactly those words.

```yaml
      - widget: chart
        label: Burndown
        from: issues                 # an OPERATIONAL block — it has an event log
        history:
          of: Status                 # the column whose DECLARED values are the states
          since: "-28d"              # -28d · -12w · -6m, or an ISO date
          every: day                 # day · week · month (UTC; weeks start Monday)
          open:      [Triage, Backlog, Todo, In Progress]
          done:      [Done]
          cancelled: [Canceled, Duplicate]
        x: { label: Day }
        series:
          - { mark: burndown, label: Remaining }
          - { mark: burnup,   label: Completed, scope: true }

      - widget: chart
        label: Cumulative flow
        from: issues
        history: { of: Status, since: "-90d", every: week, open: [...], done: [Done] }
        series: [ { mark: flow } ]   # ONE entry — one band per DECLARED value

      - widget: chart
        label: Cycle time
        from: issues
        history: { of: Status, since: "-90d", open: [...], done: [Done] }
        series: [ { mark: cycle-time, buckets: [1, 3, 7, 14, 30] } ]
```

What each mark counts, exactly:

* **`burndown`** — records in the `open:` group at the **end** of each bucket.
* **`burnup`** — records that had reached the `done:` group. `scope: true`
  adds a second line for `open` + `done` together, so a flat burnup cannot
  hide arriving work.
* **`flow`** — one **stacked band per declared value of `of:`**, in the
  format's declared order, coloured through the format's own declared colours.
  One `series:` entry, however many values.
* **`cycle-time`** — a **distribution**, not a time series: how long records
  took to pass from an `open:` value into a `done:` value inside the window,
  binned by the series' own rising `buckets:` day edges. The median is stated
  under the chart.

Read the honesty rules before you use these, because they are why the numbers
can be trusted:

* **Nothing is truncated.** A window past 366 days, past 400 buckets, or past
  50 000 events in the window is **refused by name** — a truncated burndown is
  a wrong number that looks right.
* **Cycle time excludes rather than guesses, and says how many.** A record
  whose passage began before the window, whose baseline came from a wholesale
  `migrate` (a whole batch stamped with one instant), or that reached `done`
  without ever being `open`, is left out **and counted** in the footnote under
  the chart.
* **`history:` needs an operational block.** A canonical block's history *is*
  its git history; answering from an empty log would claim nothing ever
  happened to records whose every change is a commit.
* **`where:` and `x.by` are refused beside it.** A filter over what a record
  says now cannot be applied to a record that has since changed, and the
  buckets already *are* the x axis. `x: { label: … }` is still yours.
* **One chart, one x axis.** `cycle-time` draws over days and the other three
  draw over buckets, so mixing them refuses. Declare two charts.
* **A window is re-resolved against the reader's clock.** `since: "-28d"` on a
  live page means the last 28 days *now*, not 28 days before the bake.
* **Where there is no event log in reach** — a self-contained export, a
  selftest — the widget renders a note saying what it reads, rather than an
  empty chart that looks like a real zero.

## Kanban — `widget: board`

`board` fans the SAME rows selection into lanes. Lanes are the `lanes.by`
column's DECLARED enum vocabulary, in declared order — never observed values —
coloured by `shared.colors`, with the format's per-value `about:` as the
lane's hover and empty copy. `lanes.order` subsets/reorders the vocabulary,
`lanes.empty` names the lane for rows whose cell is unset, and `wip:` puts a
number in a lane's header (`3/2` reads over-limit). Cards are the block's own
card grammar (`display.columns[].card`), and each card hosts the lane
column's `control:` menu — moving a card is the same referee-gated
single-field write the table cell posts. `lanes.by: state` (the derived
state) makes a read-only board: evidence moves those cards, not a menu.
`filter` / `filters` / `search` attach exactly as on `widget: rows`.

On a writable board the cards also DRAG between lanes. The gesture needs no
key and has none: it rides the lane column's `control:` — wherever the menu
renders, the card drags, and a board nobody can write through (the derived
state, a lane column with no bound control, a read-only host) drags nothing.
A drop posts exactly what the menu's pick posts — one write path, one referee
gate — and a refusal snaps the card back with the referee's message; the
unset lane is never a drop target, because its value is outside the declared
vocabulary and no form could spell that write. Drag is pointer-only: the lane
menu stays on every draggable card as the accessible path (keyboard, screen
reader, touch tap).

`rows: { by: <column> }` adds a SECOND axis — horizontal swimlane bands, one
per declared value of the `rows.by` enum column (or the derive vocabulary for
`rows.by: state`), with the same `order:` / `empty:` grammar `lanes:` carries.
`rows.by` must be a different axis than `lanes.by` — the same column on both
is refused. Each band shows its count and collapses/expands per reader
(persisted like the nav's collapse), and the full lane strip repeats inside
every band. Cards drag only WITHIN their own band: the drop writes the lane
column alone, and a cross-band drop cancels (snap-back, no POST) rather than
tearing the move into two writes only one of which the referee might allow —
the band column's own `control:` menu, wherever the card renders it, is the
way a card changes bands.

```yaml
      - widget: board
        label: Features
        from: features
        lanes: { by: Status, order: [open, doing, done], empty: Unsorted }
        rows: { by: Priority, empty: Untriaged }
        wip: { doing: 3 }
        filter: { by: document, label: Epic }
```

An undeclared or non-enum `lanes.by`, an `order:`/`wip:` word outside the
vocabulary, a repeated `order:` word, a lane column the page carries no value
for, a fanned block that does not declare the lane column, a key the board
does not accept yet (`sort:`, a typo'd `oder:`), or `drag:` — refused not
because it is planned but because it will never be a key: the gesture rides
the lane control's existence, and the refusal says so — each is a load
error quoting the declared vocabulary, never an empty board at runtime. On a
board-only view the block's append forms surface once below the lanes, so the
"Add task" flow never requires leaving the board.

## Timeline — `widget: timeline`

`timeline` draws items as bars on a horizontal time grid — the gantt. Two
sources, one payload shape: `select: concepts`
draws one bar per document, `bind.start`/`bind.end` naming frontmatter `date`
fields, bars coloured by document type; `from: <block>` draws one bar per row,
`bind` naming date columns and `color:` optionally naming a column whose
values colour bars through `shared.colors`. Dates are `YYYY-MM-DD`, the same
grammar the `date` field kind takes.

```yaml
      - { widget: timeline, label: Delivery, from: tickets,
          where: "state != 'done'",
          bind: { start: Start, end: Due, progress: Done }, color: Kind }

      - { widget: timeline, label: Epics, select: concepts,
          where: "type == 'epic'",
          bind: { start: start, end: end,
                  progress: { from: tickets, done: "state == 'done'" } } }
```

The frozen left rail is the document title (or the row's card-title column)
and opens the document; the grid spans the data's own dates with week or month
ticks and a today line only when today falls inside it. An item missing either
date is LISTED under the rail with the reason in the format's own column names
— never silently dropped — and a malformed date (or an end before its start)
is a finding naming the item, never a broken bar. A bad `bind` is a load error
with did-you-mean; the four core document fields (`title` · `description` ·
`tags` · `timestamp`) are refused as binds — the IR keeps them outside
frontmatter meta, so they can never carry a bar.

In rows mode `bind.depends` names a `ref:` column (`Depends` pointing at the
block's own ids, say): each item then carries its resolved row-refs — the
target's key, derived state and uuid when the record has one — and the widget
draws a connector between the target's bar and the item's. Both endpoints must
be drawn bars in the same widget; a dep whose target is listed without a bar
or filtered out by `where:` still ships in the payload, it just has no two
points to connect. A `bind.depends` naming a column with no `ref:` is refused.
On a board, the same resolved row-refs badge each card with the ref id and the
target row's derived-state chip — no `bind` needed there, the card shows every
typed ref its row carries. Planned spellings (`zoom:`, `duration`/`parent`
binds) are refused loudly today, never silently ignored — tomorrow's key
cannot mean nothing on today's engine.

**`bind.progress`** fills each bar two-tone — the bar's own resolved colour
strong to the fraction, the same colour softened for the remainder. The bind
is mode-polymorphic, and each mode refuses the other's spelling loudly. In
CONCEPTS mode it is `{ from: <block>, done: "<query>" }`: the format's own
predicate for a complete row (the engine never knows what "complete" means),
compiled at load — a bad query or unknown block is a load error — and counted
per document over THAT document's OWN rows of the named block; the count
travels in the payload and the bar's hover ("2 of 5"), and a document with
zero rows is 0 of 0, shown as 0%, never hidden. In ROWS mode it names a
number column carrying each row's own fraction, normalized by one rule: at
or below 1 a fraction of 1, above 1 a percentage of 100 ("0.4" and "40" both
mean 40%); outside 0..100 clamps with a finding naming the row, a non-number
cell is a finding on a bar with no fill (its dates are fine, so it keeps its
bar), and an empty cell is simply a row without progress — no finding, no
fill. Progress is server-computed: in proposal mode a staged date gesture
keeps its pre-stage fill until the change set lands.

In rows mode, on a writing host, bars WRITE: a block that declares an edit
form of exactly two `date` fields `set:` onto the two bound columns (e.g.
`set: { Start: "{start}", Due: "{end}" }`) makes its bars draggable (move) and
resizable at either edge. The settled gesture snaps to the visible grid,
previews its would-be dates, and posts the SAME op that form's submit
produces — referee-gated, refusal = snap-back with the referee's message, and
proposal mode stages it. Clicking a bar (or the rail's edit button — also the
path for an item listed without a bar) opens that form as the edit popup. No
such form: the timeline is read-only in fact, plain bars, no handles.

`select: concepts` bars write the same way, through the TYPE's own form: a
type may declare a frontmatter form (`types.<t>.forms` — the block-form
grammar over declared `date` frontmatter fields; see the format-authoring
skill) and a concepts bar whose document's type declares one of exactly two
`date` fields `set:` onto the two bound fields (each a bare `{field}`
template) drags, resizes and click-opens that form as its popup — one write
path, one referee gate, snap-back on refusal, staged in proposal mode. A
type declaring no such form keeps byte-inert bars: read-only in fact, plain,
no handles.

## One board per document — `for_each:`

A view can be a TEMPLATE. `for_each:` instantiates it once per document of a
type, substituting that document's fields into every string in the view —
`{title}`, `{path}`, `{type}`, `{domain}` and any declared frontmatter field.
One definition, one board per person (or per app, per team, per service).

```yaml
views:
  - id: person-board
    label: "{title}"
    for_each: { type: person }          # optional `where:` narrows it further
    widgets:
      - { widget: note, text: "Everything assigned to {title}." }
      - widget: stat-row
        indicators:                      # declared INLINE, so the numbers are theirs
          - { label: Assigned, select: rows, from: features,
              where: "owner contains '{path}'" }
          - { label: Untested, select: rows, from: features,
              where: "owner contains '{path}' && state == built && !cites_test" }
      - { widget: rows, label: Assigned features, from: features,
          where: "owner contains '{path}'", search: { by: [state, Kind, document] } }

navigation:
  - group: Team
    items:
      - { views: person-board }                # one entry per instance…
      # - { label: Team, views: person-board }  # …or nested under a heading
```

Three things to get right:

- **Match on `{path}`, not `{title}`.** The path is identity, and it is what a
  link inside a cell actually contains. Two people can share a name.
- **`contains` is the operator for a cell that HOLDS a value** rather than
  being it — a link, a `;`-separated list, a sentence citing an id.
- **`stat-row` takes inline indicators**, not just the ids of declared ones.
  That is the only way a per-document board carries its own numbers: the
  `where:` is the only thing that differs between instances.

Instances are path-sorted, so the set is stable, and each one is an ordinary
view id — deep-linkable, and usable anywhere a view id is.

## Meaning is mandatory (catalog I1)

Every visual encoding must carry what it means — a legend, a caption, a
callout. A colour with no legend renders a visible `⚠` marker rather than a
naked encoding, and that marker is a bug to fix in the definition, not a style
choice. Colour vocabulary is `shared.colors` in format.yaml: the engine ships
NO word meanings, so `built`, `open`, `blocked` mean whatever your format says
and nothing at all if it says nothing.

## How a block's rows draw

`blocks.<b>.display` is where a block's presentation lives — one place, applied
everywhere those rows appear (inside a document, in a rows view, in a composed
widget).

```yaml
    display:
      intro: prose
      layout: { narrow: cards }        # table | cards — a table is unusable on a phone
      # layout: { narrow: cards, wide: cards }   # cards at EVERY width — for
      #   blocks that read as a feed of entries rather than a comparison table
      columns:
        - { col: Q,        style: mono, card: eyebrow }
        - { col: Question, style: md,   card: title }
        - { col: Status,   style: chip, card: badge, control: { form: set-status } }
        - { col: Notes,    style: md,   card: hide }
```

- **styles**: `plain` (escaped text) · `mono` (ids, paths) · `md` (inline
  markdown — links, code, evidence URIs) · `chip` (a pill coloured by
  `shared.colors`) · `progress` (an enum value drawn as where it sits in its
  declared order — ring, check, ×) · `person` (a handle, or a comma/space
  list of handles, drawn as name chips through the host-supplied people
  directory — `people.yaml` beside format.yaml, or `--people`; an unresolved
  handle renders as the muted handle itself, never an error).
- **card roles** (what a column becomes when rendered as a card — below the
  narrow breakpoint, or everywhere with `wide: cards`):
  `eyebrow` · `title` · `badge` · `field` (default, a labelled value) · `hide`.
- `state: marker` on a column dots it with the row's derived state colour.
- `control:` puts an inline editor on the cell — see the forms skill.

An unknown layout or card role is a definition **error**, not a silent fallback:
a typo must not keep rendering as a table.

**`filters:` on a block's display** — the power search on every document page
that renders the block, not just in a composed view. A list of `state` (the
derived state — needs a `derive.state`) and/or **displayed** column names; the
reader gets the property · operator · value token bar, scoped per document +
block. Enum columns offer their **declared** values; any other column matches
as text, so presence questions are operators, not extra controls ("Gap is not
empty", "Tests is empty"). A name that is not a column, a column outside
`display.columns`, or a non-list value are load **errors** — a filter must
never be a dead control.

```yaml
    display:
      filters: [state, Kind, Gap, Tests]
```

## Controls — narrowing what a view shows

These change what is displayed, never what the query selected.

**`filter:`** — a facet with counts. On concepts it facets a frontmatter field;
on rows, a column. `default:` opens pre-filtered with every bucket one click
away.

```yaml
      - { label: Doubts, select: rows, from: [open-questions, anomalies],
          filter: { by: Status, label: Status, default: open } }
```

**`as:` and `multi:`** — how a facet renders. `as: tabs` (the default) draws the
segmented strip above, one option per observed value — right for a handful of
buckets. `as: dropdown` draws a compact, searchable dropdown instead, for a
high-cardinality field where a strip would overflow (owners, a `root:` rollup
over a large tree). `multi: true` makes it multi-select: chosen values **OR**
together (rows in ANY selected bucket), and nothing selected still means all.
Tabs stay single-select; leave both off and the facet is byte-for-byte the
pre-existing control.

```yaml
      # a searchable, multi-select dropdown over a wide field
      - { label: Work, select: rows, from: features,
          filter: { by: Owner, label: Owner, as: dropdown, multi: true } }
```

**`filters:`** — a **list** of facets on one view, in ADDITION to (or instead
of) the singular `filter:`. They **AND** across (a row must pass every facet),
while a multi-select facet ORs its own values — so you can keep a small facet as
tabs and add a high-cardinality one as a multi-select dropdown on the same view.
Each entry takes the full facet vocabulary (`by` · `label` · `empty` ·
`default` · `root` · `as` · `multi`).

```yaml
      # keep a small Status facet as tabs, add a high-cardinality Owner facet
      # as a multi-select dropdown — Status == open AND Owner ∈ {chosen}
      - { label: Open work, select: rows, from: features,
          filter: { by: Status, label: Status, default: open },
          filters: [ { by: Owner, label: Owner, as: dropdown, multi: true } ] }
```

**`filter: { by: <field>, root: true }`** — faceting by the ROOT of a
self-referencing hierarchy. `by` names a self-referencing frontmatter field (the
same kind `tree:` walks — any field whose value is the title of another
document), and each row or concept is bucketed under the **topmost ancestor** its
chain reaches, resolving each value to a document by title or `name`. A document
with no value for that field — or one nothing else points through — is its own
root. The walk is cycle-guarded, and the rollup is computed once over the whole
corpus, so `root: true` works identically on `select: rows` and
`select: concepts`. A `by` that is not a resolvable self-referencing field is a
load **error**, not a silent empty facet.

```yaml
      # bucket every task under the top-level initiative it descends from,
      # however deep the parent chain goes
      - { label: Tasks by initiative, select: rows, from: tasks,
          filter: { by: parent, root: true, label: Initiative } }
```

A `root:` rollup often has many buckets, so it pairs naturally with
`as: dropdown, multi: true` — the rollup is computed the same way, the dropdown
just makes a wide result usable.

A `select: rows` query also **inherits its document's frontmatter**: a row can be
filtered by any field the containing document declares (`app`, `status`,
`parent`, anything authored), exactly as a `select: concepts` query can — the
row's own cell columns and the reserved fields (`state`, `type`, `document`)
still take precedence where names collide. So `where: "app == Storefront"` over a
rows view keeps only rows whose *document* names that app, no column required.

**`search:`** — power search: composable tokens of *property · operator ·
value*, for when one facet cannot express the question ("assigned to Ross, of
kind rule, still lacking evidence").

```yaml
      - { label: Work, select: rows, from: features,
          search: { by: [ { property: Owner, label: Assigned to, from_concepts: person },
                          Kind, state, type, document, Gap ] } }
```

A property is a declared **column** of the selected block(s), or one of the
three derived properties every row has: `state`, `document`, `type`. Its
vocabulary is whatever the format already knows — the column's `enum:`, the
titles of a concept type (`from_concepts:`), an explicit `values:` list, or the
values the documents actually hold; past ~25 distinct values it degrades to a
text match rather than an unusable list. Vocabularies offer `is` / `is not` /
`is any of`; text offers `contains` / `is` / `is empty` / `is not empty`. The
engine supplies the operators and nothing else. Tokens compose with AND, each is
removable on its own, and `search:` stacks with `filter:`.

**`tree:`** — nest a concepts view by a self-referencing frontmatter field, so
documents that name a parent render as a hierarchy instead of a flat list.
Anything whose parent is empty, unmatched, or would close a cycle becomes a
root: a hierarchy the documents got wrong still renders, it just renders flat.

```yaml
      - { label: Flows, select: concepts, where: "type == flow", tree: { by: parent_flow } }
```

**`explorer:`** — declared on a block, not a view: a path tree that branches by
one column and converges on another.

```yaml
    display: { explorer: { kind: Kind, result: Result } }
```

**`graph:`** — also declared on a block: a process chart chaining a document
and its `children` from the block's rows. Nodes are documents (a linked `to`
cell), each edge carries its `when` rule revealed on hover, unlinked `to`
cells converge as terminal outcome boxes, and rollback edges draw as dashed
returns below the chart. Zoom/pan built in; clicking a node opens it.

```yaml
    display: { graph: { to: To, when: When, children: parent_flow } }
```

## Choosing a shape

| The reader needs… | Compose |
| --- | --- |
| one number, prominent | `stat-row` over declared indicators |
| "how complete is each type" | `coverage` (bars) — legend required |
| "what should I work on" | `rows` + `search:` — actionable in place, no document-hopping |
| "which of these documents…" | `concepts` + `filter:`, or `tree:` where a hierarchy exists |
| "what exists that nothing cites" | `reverse-gap` (needs `--inventory`) |
| "what's in flight, by stage" | `board` — lanes over a declared enum column |
| "when does this land" | `timeline` — bars over date columns or frontmatter dates |
| "how do these relate" | `graph` with declared `nodes`/`edges` |
| a triage queue | a composed view: note + stat-row + one `rows` widget per queue |

Prefer a `rows` view over a `concepts` list whenever the reader's next action is
on a row: rows views render the block table itself, so forms, controls and
evidence links are all right there.

## Rules that keep a composition honest

- Filtering narrows a **query**, never hides DOM — counts stay true.
- Every number traces to a query binding; a filtered or derived metric states
  its filter in its caption.
- Inventory-dependent widgets degrade to "unavailable" without `--inventory`,
  never to zeros — a missing input must not read as a clean bill of health.
- Nothing in the catalogue carries domain meaning. A widget knows how to draw a
  chip; what "open" means is your format's to say.
