table view: metadata projection

thinking doc · 2026-08-16 · answers "does a metadata table view make sense?" · touches Lens Spec Draft 3 §11.1 (auto table columns, deferred), Block Properties, River, AI as Blocks

Yes — with one correction that changes where it lives. River and grid are two orderings of the same blocks: linear and spatial. They work on any set, because they need nothing from a block but that it exists. A table needs more: it needs the rows to agree on a key space. That isn't a third ordering, it's a projection — so it doesn't belong next to :grid as a page-level toggle. It belongs to the thing that already defines a set with a shared shape: the lens.

River orders. Grid places. A table only exists where the rows agree.

Practically: Mod+\ flips the page river to grid and back, and that's fine forever. Table is {table} in a lens line — a region-level presentation. A whole-page table is then just the degenerate case (a lens over the page river), and it degrades honestly rather than pretending Home is a spreadsheet.

1 · The good case: columns you never declared

The reason this is worth building is that you already have the data and currently can't see it side by side. Three blocks tagged :dogs: with breed keys are, right now, three unrelated sentences.

pets
![[:dogs:]]{table sort:weight-desc}
block breed weight vet
*Thorsamoyed24 kgdr. holm
*Pepperwhippet11 kg
*Nubbincorgi — found him at the shelter on…
columns breedweightvet 33%+ chip+ adopted+ created

Columns are observed, not declared. Every key that appears on ≥1 row is a candidate; the header carries a coverage bar and the default shown set is coverage ≥ 50% plus anything the lens line names explicitly. Sparse keys stay one click away in the column bar. No schema, no migration, no "add property to type" — consistent with the whole point of tags-not-classes.

2 · The one part of your framing to reject

"Disregard the content and text" — almost. Drop the body, keep the name. Without the block's first line the rows aren't identifiable and nothing is clickable back to the source; a table of anonymous macro numbers is a worse spreadsheet, not a better outliner. So:

3 · Types are inferred per column, and that's what earns the footer

Macros are the strongest case precisely because the payoff isn't reading — it's arithmetic and entry, neither of which river or grid can ever offer.

journal/2026-08-16
![[:meal:]]{table when:today}
blockkcalproteinfatsugarwhere
*oats + skyr42031912home
*chicken bowl61048186out
*evening snack2407home
3 rows1270862718
next cell · commit, drop a row · ⌥⏎ new :meal: block with today's keys pre-seeded

Inference, with an override stored in the lens text (col:sugar=num) so it survives a reload and travels with a shared lens: number → right-aligned, tabular figures, sum in the footer (avg for keys that look like ratings); date → relative, sortable; few distinct values → chip, and clicking one narrows the lens instead of sorting; everything else → truncated text. Guessing is safe here because every guess is one keystroke from being corrected and nothing about storage changes.

Entry, not reading, is the MVP

Filling in macros for seven meals in the river means seven blocks, seven :PROPERTIES: lines, endless key retyping. In a table it's a tab-grid: keys already exist as columns, so you type values. If you only ship one half of this feature, ship editable cells. A read-only table is a nice report; an editable one is the reason the metadata gets filled in at all — and unfilled metadata is why the feature would otherwise look useless in month two.

4 · Blanks are the feature

A blank cell must render as blank (·), never 0, never in a way that reads as a value. The table is the only view in LifeLab that can show you what you haven't recorded: Nubbin has no weight, the snack has no fat. That makes the table double as a checklist for backfilling, and it's why coverage belongs in the header rather than hidden in a settings pane.

5 · Sentiment: computed columns are a different animal

Diary sentiment is not a key you typed — it's an invocation result. Same table, but the column has to say so, or you get silent fabricated data sitting next to hand-entered truth.

journal · august
![[:diary:]]{table when:-2w.. cols:mood,ƒsentiment,ƒtopics}
blockmood sentimenttopics
*tue — long walk— slept badly but the lake was…ok+0.31sleep, outdoors
*wed— review went sideways, and…−0.52work
*thu— quiet, finished the samoyed…stale ⟳stale ⟳

Stored vs derived is a visible distinction, not a footnote: derived headers are italic with a ƒ, cells are italic and muted, and they are not editable — editing one would mean writing a model's opinion into your notes as if you'd said it. Values are cached against block content hash; edit the block and the cell greys to stale ⟳ until recomputed. Recompute is explicit and per-column, because it costs money and the user should see when it's about to. A derived column can be promoted to a real key by an explicit action — that's the one path from opinion to data, and it should be deliberate.

6 · The failure case, and the fix

Point a table at Home and the honest answer is that Home has no key space. 40 tags, 60 keys, 3% coverage each — a horizon of empty cells that makes the feature look broken.

one wide sparse table
blockbreedkcaldueeramin
Thorsamoyed
implement minJEPAfri
article ideas1970s
Alan Wake 2172

Every row is mostly absence. Nothing sorts, nothing sums, and the eye has no column to hold.

a table per key space
:dogs: 3
blockbreedweight
Thorsamoyed24 kg
Pepperwhippet11 kg
:logbook: 12
blockentriestotal
Mandarin study356h 13m
Alan Wake 212h 52m
untabled 41

blocks with no keys in common — show as river ↓

Cluster the set by observed key space (in practice: by tag), one small table each, and put everything keyless in a plain river tail. Absence is stated once instead of drawn 200 times.

Note what this needs: group:tag, which Draft 3 §11.1 defers. That's the real dependency — table view is mostly blocked on grouping, not on tables. Worth knowing before scheduling it.

7 · What it writes

Nothing new. Sort, chosen columns, type overrides and group all serialize into the lens line, exactly as §9 insists — no view_state table, no per-user column widths in a database. Reordering a column edits text; sharing a lens shares its table.

![[:meal:]]{table when:-1w.. cols:kcal,protein sort:kcal-desc col:kcal=num}
![[:dogs:]]{table group:tag}
![[Projects]]{todo table cols:due,est sort:due-asc}   ← agenda as a grid

And writes back the same way any lens does: a new row created in the table is a new block marked with the lens's tags, values written as keys. The + new row affordance is the lens's existing add-affordance wearing a table's clothes — not a new creation path.

8 · Aggregation is this same view, collapsed

Strongly — and in the best way: they're one feature at two zoom levels, and you've already built the harder half. A table is a projection of a set onto a key space, one row per block. An aggregate is the same projection with the rows collapsed by a grain and each column run through a reducer. The footer in §3 is not a table feature I invented; it is your monthly aggregation with grain:all. If those two end up as separate code paths, that's the actual mistake to avoid here.

One grammar, three knobs: which keys (columns) · what a row is (grain) · how to collapse (reducer).

journal/2026-08 · monthly
![[:meal:]]{table when:this-month grain:day cols:kcal:sum,protein:sum,mood:mode}
day · 3 blocks avgkcal Σprotein Σmood ◆rows
mon 102140128ok4
tue 11198096good3
wed 120
thu 13 127086ok3
↳ oats + skyr42031
↳ chicken bowl61048
↳ evening snack2407
august · 13 days24 3801 402ok42
a row is a group · on a group drops one grain level in place · footer = the same reducers at grain:all

Drill-down comes free and needs no new concept. Expanding thu 13 doesn't open a different view — it lowers the grain for one group, so the child rows are the §3 table verbatim. Collapse it and you're back to the monthly aggregate. Blank stays blank at every grain: wed 12 has no meals, which is worth seeing and is not 0 kcal.

What has to be shared, concretely

The one thing that doesn't transfer: writing. A leaf row is editable; an aggregate cell is a computed value with no single block to write to, so it's read-only for the same reason the ƒ columns are. Same greyed treatment, different reason — worth saying out loud in the spec so it doesn't look like an oversight.

This also settles §6's dependency: the grouping I said the table needs is your grain:. If aggregation shipped with grouping, table view is much closer to free than I priced it above.

9 · Verdict

callwhy
yesBuild it as a lens presentation{table}, region-level. It's the only view that can sum, backfill, and expose absence.
yesEditable cells in v1. Reading is the smaller half; the tab-grid is what makes people record metadata at all.
not thisNot a third page-level view next to river/grid, and not a page-wide table by default. Different class of thing: projection, not ordering.
not thisNo declared columns, no schema-per-tag. Observe keys, rank by coverage, let the lens line pin exceptions.
yesBuild it on the aggregation engine you just shipped (§8) — same reducers, same grain, table is only the drawing. Footers and drill-down then cost nothing.
after gridGrid is broken today and shares the "same blocks, different projection" plumbing. Fix grid first; the table inherits the fix.

10 · Open, and I'd want your call