LifeLab · lens ↔ outliner
You're right, and it's worth stating precisely: an outline node is a lens whose query is "the blocks I contain" — implicit, positional, single-parent. A lens is that same container with the query written down, so membership is decided by a predicate instead of by where you pressed tab. Outliners do containment by position; lenses do containment by criteria. Everything else — fold, zoom, drag, indent — is shared, and should stay shared.
This is the whole model. One tree, one set of gestures, three answers to "where do my children come from" — and the badge/gutter on each node tells you which, which is exactly the information that makes × and drag unambiguous. The nesting-darker idea was trying to encode depth; what actually needs encoding is which of these three a node is.
| outliner | on a stored node | on a referenced / computed node |
|---|---|---|
| fold tab | Hide the subtree. | Identical. Fold is fold; a lens is just a node with children. |
| zoom ⏎ | That node becomes the root of the view. | That lens becomes the whole view — same lens, new frame. This is the tab-killer: zoom is already the outliner's answer to "open in a new window", and it works unchanged on a query. |
| indent tab | Reparent onto the sibling above. | Under a computed node there is no position to take, so indent means acquire the predicate — the block gets the tag/status that puts it in the set. Same keystroke, same intent ("belong to that"), different mechanism. |
| move ⌥↑↓ | Reorder siblings. | Disabled inside a computed node — order is the lens's sort:, not yours. The lens header is where you change it. This is the one place the metaphor genuinely breaks, and it should be visible: no drag handles on computed rows. |
| new sibling ⏎ | Insert a block after this one. | The ghost-add row, pre-filled to qualify — typing under {todo :urgent:} gives you a TODO tagged :urgent:. The predicate becomes a template. |
| breadcrumb | Path of stored ancestors. | Path of frames — the lenses you zoomed through. Mixed freely: journal/07-22 › project/LifeLab › :blocked:. |
The translation table is the argument. Five of six verbs survive untouched, which is the sign the abstraction is right rather than clever. The sixth — manual reordering — fails for a good reason, and failing loudly (no handle, no drop line) teaches the model better than any label would.
A lens header is a bullet. Not a panel, not a card, not a section rule — a node with a badge. That settles the aesthetics question: lenses shouldn't look like containers, they should look like outline nodes that declare their source. Same indent, same fold, same hit target.
Indent already encodes depth, so the darker-nesting idea has nothing left to say. Spend the visual channel on the badge/gutter — stored, referenced, computed — because that changes what your keys do.
The page view is the root node. Every outliner has an implicit root; here it's a lens like any other, which is why the title can carry filter chips and why reframing is an edit rather than navigation.
Naming is the actual upgrade. An outline node's query is unwritten and unnameable, so it can't be reused, shared, or reasoned about. ![[page]]{todo} is the same containment made quotable — that's the whole delta, and it's why lenses compose and folders never did.
the honest tension
A block can now appear in many places, so "where is it?" stops having one answer. Outliners are lovable partly because position is identity. The rule that saves it: exactly one producer is stored — that's home, it's always shown in the rail when you're not looking at it from home, and every other appearance is explicitly a window or a match. Multiplicity is fine as long as home is never in doubt.
what I'd build first
Make the three producers a real union in the model, not three renderers — one node with a children_source of stored | ref | query. Then fold, zoom, drag, breadcrumb and keyboard nav are written once, and the lens stops being a feature bolted onto the outliner and becomes the thing the outliner was a special case of.