table view: metadata projection
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.
| block | breed | weight | vet |
|---|---|---|---|
| *Thor | samoyed | 24 kg | dr. holm |
| *Pepper | whippet | 11 kg | |
| *Nubbin | corgi — found him at the shelter on… |
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:
- Column 0 is the block itself, pinned, always present, never a metadata key. Task verb badge included — that's metadata that renders as a prefix.
- Body text past the first line collapses to a dim one-line ghost (Nubbin, above). ⏎ on the row opens the block in place, full outline, mid-table — the same expand-in-place you use for transclusion.
- Ghost text is never a column. The moment prose gets a column it gets a width, and the table becomes a bad river.
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.
| block | kcal | protein | fat | sugar | where |
|---|---|---|---|---|---|
| *oats + skyr | 420 | 31 | 9 | 12 | home |
| *chicken bowl | 610 | 48 | 18 | 6 | out |
| *evening snack | 240 | 7▏ | home | ||
| 3 rows | 1270 | 86 | 27 | 18 |
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.
| block | mood | sentiment | topics |
|---|---|---|---|
| *tue — long walk— slept badly but the lake was… | ok | +0.31 | sleep, outdoors |
| *wed— review went sideways, and… | −0.52 | work | |
| *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.
| block | breed | kcal | due | era | min |
|---|---|---|---|---|---|
| Thor | samoyed | ||||
| implement minJEPA | fri | ||||
| article ideas | 1970s | ||||
| Alan Wake 2 | 172 |
Every row is mostly absence. Nothing sorts, nothing sums, and the eye has no column to hold.
| block | breed | weight |
|---|---|---|
| Thor | samoyed | 24 kg |
| Pepper | whippet | 11 kg |
| block | entries | total |
|---|---|---|
| Mandarin study | 35 | 6h 13m |
| Alan Wake 2 | 1 | 2h 52m |
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).
| day · 3 blocks avg | kcal Σ | protein Σ | mood ◆ | rows |
|---|---|---|---|---|
| mon 10 | 2140 | 128 | ok | 4 |
| tue 11 | 1980 | 96 | good | 3 |
| wed 12 | 0 | |||
| thu 13 ▾ | 1270 | 86 | ok | 3 |
| ↳ oats + skyr | 420 | 31 | ||
| ↳ chicken bowl | 610 | 48 | ||
| ↳ evening snack | 240 | 7 | ||
| august · 13 days | 24 380 | 1 402 | ok | 42 |
grain:allDrill-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 reducer vocabulary. Whatever names your monthly aggregation already uses —
sum,avg,count,min/max,modefor enums,latestfor dates — the table must use those exact words. Two spellings for "total" is the failure mode. - The type→default-reducer table. §3's inference is really your inference: number →
sum, rating-ish →avg, few-distinct →mode, date →max, text →count. Define it once, in the aggregation layer; the table just displays what it returns. - Grain, not "monthly view". If aggregation is currently reachable only through the month lens, generalize the parameter:
grain:day|week|month|tag|none. Then "monthly" is a preset, weekly is free, and Month Lenses becomes one more presentation of the same reduced set. - Presentation is orthogonal to reduction. Reduce with
grain:/reducers; draw withtable, a river line, a month cell, or an inline number. Your existing inline monthly figure isgrain:all cols:kcal:sumdrawn inline — a 1×1 table. Keep those two axes separate and the table is nearly free; fuse them and you'll write the sum three times.
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
| call | why |
|---|---|
| yes | Build it as a lens presentation — {table}, region-level. It's the only view that can sum, backfill, and expose absence. |
| yes | Editable cells in v1. Reading is the smaller half; the tab-grid is what makes people record metadata at all. |
| not this | Not 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 this | No declared columns, no schema-per-tag. Observe keys, rank by coverage, let the lens line pin exceptions. |
| yes | Build 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 grid | Grid 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
- What syntax did the aggregation land with? §8 is written against a guessed
grain:/key:reducerspelling. Paste the real one and I'll rewrite the table grammar to sit inside it rather than beside it. - Does a table row ever transclude? A row whose block has children — table for the parents, or one flat row per leaf? (I'd say: parents only, children behind ⏎.)
- Sum in the footer per column, or one summary row per group? Macros want per-day totals, which is a grouped table, which is again
group:taggeneralized togroup:day. - Are keys typed globally or per-tag? If
weightis kg for dogs and lbs for the barbell log, an override in the lens line is fine — a global key registry is a schema by another name. - Is a table ever the default? Argument for:
:meal:and:logbook:pages are basically always tables. That would mean a tag remembering a preferred presentation — a real change to what a tag is, and worth its own doc.