A mid-level declarative rendering layer for wanderland-core engines. Layout definitions are YAML resource files that double as scenario tests. Components are server-side boundaries that emit HTML with optional web component enhancement on the client.
The goal: markdown + YAML + known actions, composable by editing. Notion/Obsidian without the things that suck. No proprietary block editors, no plugin ecosystems. Server-rendered pages backed by boundaries you were already authorized to call, with buttons that compose them into workflows.
Three layers. Layout containers, mid-level components, and one low-level primitive.
vlayout and hlayout — vertical and horizontal stacking. Children rendered in order. Already exist in the lantern engine.
type: vlayout
children:
- component: filter-bar
args: { ... }
- component: record-list
args: { ... }
Content-type units with natural boundaries. Each one is a server-side boundary that takes bound data and emits HTML.
| Component | What it is |
|---|---|
oculus-modeline |
Graph node — identity, status, actions across four projections |
task-modeline |
Detective case — identity, status, actions across four projections |
filter-bar |
Composable controls above any list (tabs, sort, az-picker, tag-filter) |
record-list |
Any list of modeline items with grouping |
markdown-content |
Rendered markdown body with KaTeX + Mermaid + syntax highlighting |
tag-cloud |
Weighted tag display with counts |
One: oculus-button. Everything else is mid-level or higher.
A modeline is a single entity rendered at different fidelities depending on context. The data shape never changes — the projection selects which attributes render and what level of interaction is available.
Four projections:
List item. Title as a link, metadata below, inline action buttons flush right. No expansion. Clicking the title navigates to the detail page.
03b-dht-predictability-time-arrow PEEK ARCHIVE
2026-03-09 1 views 22m ago
Used in: /nodes, /recent, /cases, reading lists, search results.
Panel tile. Title, description snippet, optional action button(s) in the bottom-right corner. Clicking the card navigates. Visual weight without interaction complexity.
┌─────────────────────────┐
│ Peregrine │
│ │
│ Custom developer │
│ platform in use by │
│ Fortune 500... │
│ │
└─────────────────────────┘
Used in: dashboard grids, project overviews, any place you want a visual directory.
Identity strip on a detail page. Entity tokens in a line, chevron on the right. Clickable to expand into panel. Sits above the content body.
lantern-component-framework · PRIVATE · ○ pin · tags ▸
Used in: /node/:slug, /case/:id — the header of any detail view.
The bar expanded. Copy buttons, then collapsible groups for each action domain (visibility, pin, tags, bookmarks for nodes; state transitions, evidence tags, notes for cases). Only accessible from bar projection — you're on the detail page, you want to do something.
lantern-component-framework · PRIVATE · ○ pin · tags ▾
┌─────────────────────────────────────────────────────────────────────────┐
│ [ slug ] [ url ] [ txt ] │
│ ▸ visibility │
│ ▸ pin │
│ ┌ pin to label... [+] ┐ │
│ ▸ tags │
│ ▸ bookmarks │
└─────────────────────────────────────────────────────────────────────────┘
# Row in a list
- component: record-list
args:
data: $data.nodes
record: oculus-modeline
projection: row
href: /node/{slug}
actions:
- [peek, /api/node/{slug}/preview]
- [archive, /node/{slug}/archive, post]
group-by: first-letter
# Card in a grid
- component: record-list
args:
data: $data.projects
record: oculus-modeline
projection: card
href: "{url}"
# Bar on a detail page
- component: oculus-modeline
args:
data: $data.node
projection: bar
Action paths and display fields reference entity attributes with {attribute} syntax. The attribute resolves against the bound data record — the same data shape that powers every projection.
{slug}, {url}, {id}, {title}, {status}, {modified} — whatever the entity has. A card bound to a project gets {title} and {description} for display. A row bound to a node gets {slug} for display and /node/{slug} for navigation. Same component, different field bindings.
The sole low-level primitive. A (label, path) tuple rendered as a button. Can do three things:
- [view, /node/{slug}]
Renders as an <a> tag. Clicking navigates.
- [start, start-case, { id: "{id}" }]
POSTs to the route's path with the provided args. The route name resolves against the site's route table.
- label: "switch to this case"
chain:
- route: park-current
- route: start-case
args:
id: "{id}"
Executes a sequence of named route boundary chains in order. Each step produces crossing records. If any step halts, the chain stops. Full tracing for free.
Every action must be a named route. The button doesn't invent behavior — it composes existing routes. The YAML route table is the complete authority for what the system can do. Buttons are references into that authority.
Drop it on any Oculus node or markdown content:
<oculus-button label="start" route="start-case" args='{"id":"task-abc123"}'></oculus-button>
Server-renders as a <form> with the route's path and method. Web component enhances if JS is loaded.
Compose into modeline action slots:
- component: oculus-modeline
args:
data: $data.node
projection: bar
panel:
- group: navigation
actions:
- [prev, /node/{prev}?rl={topic}&i={prev_index}]
- [next, /node/{next}?rl={topic}&i={next_index}]
- [exit reading, /node/{slug}]
- group: workflow
actions:
- label: "park and switch"
chain:
- route: park-current
- route: start-case
args:
id: "{case_id}"
Routes in the site config carry an optional name: attribute for referencing from buttons and chains.
routes:
/case/:id/start:
name: start-case
chain: [resolve_case, start_case]
method: post
/case/current/park:
name: park-current
chain: [resolve_current, park_case]
method: post
/node/:slug/archive:
name: archive-node
chain: [resolve_node, toggle_archive]
method: post
/api/node/:slug/preview:
name: node-preview
boundary: node_preview
method: get
The name is how oculus-button references a route. The route's chain is the boundary sequence that executes. Authorization is already handled by the boundary system — if the caller isn't authorized for a boundary in the chain, it halts. The button can only compose actions the user was already authorized to call.
When an oculus-button defines an inline chain, the engine:
{attribute} args from the bound entityThis is the same boundary execution model as a single route — just iterated. Passage can test any chain as a scenario with the same operation / input / expected shape.
Layout definitions live as YAML files in lantern/resources/layouts/. Each file follows the scenario shape.
Same file, three purposes:
name: "Nodes — filtered graph directory"
description: "A-Z filterable, sortable list of all graph nodes"
operation: page_render
input:
args:
title: Nodes
data:
nodes:
source: oculus
endpoint: nodes
params:
sort: slug
limit: 200
tags:
source: oculus
endpoint: tags
layout:
type: vlayout
children:
- component: filter-bar
args:
filters:
- type: tabs
field: status
options: [live, archived, all]
- type: sort
options: [slug, -modified, -created, -view_count]
- type: az-picker
field: slug
- type: tag-filter
data: $data.tags
- component: record-list
args:
data: $data.nodes
record: oculus-modeline
projection: row
href: /node/{slug}
actions:
- [peek, node-preview, { slug: "{slug}" }]
- [archive, archive-node, { slug: "{slug}" }, post]
group-by: first-letter
expected:
components:
includes: [filter-bar, record-list, oculus-modeline]
The site lantern.yml points routes at layout resources:
routes:
/nodes:
chain: [yaml_loader, page_render]
method: get
resource: layouts/nodes
/node/:slug:
chain: [yaml_loader, page_render]
method: get
resource: layouts/node-detail
/cases:
chain: [yaml_loader, page_render]
method: get
resource: layouts/cases
/case/:id:
chain: [yaml_loader, page_render]
method: get
resource: layouts/case-detail
/tags:
chain: [yaml_loader, page_render]
method: get
resource: layouts/tags
/reading-lists:
chain: [yaml_loader, page_render]
method: get
resource: layouts/reading-lists
request
-> route_match (engine — resolve path to route config)
-> yaml_loader (load layout resource from resources/ into context)
-> data_fetch (resolve data declarations against API backends)
-> variable_resolve (substitute $params, $query, $data, $config)
-> layout_compose (walk component tree, render each boundary)
-> frame_assemble (wrap in page chrome — nav, scripts, head, footer)
-> response
Each step is a boundary. Each produces crossing records. Scenarios can test any step in isolation or the full chain.
Existing web components (`
A `component_expand` boundary in the render chain scans rendered HTML for known component tags and resolves them server-side. The boundary calls are identical to what a layout-driven render would do — same args, same resolution, same crossing records.
This means the pipeline is extensible by adding boundaries to the chain:
```[id=extensible-pipeline]
yaml_loader → data_fetch → variable_resolve → layout_compose → component_expand → frame_assemble
Any future middleware — permission checks, caching, transforms — is just another name in the chain. The capability is there because everything is a boundary.
Components in markdown and components in layout YAML must use the same arg shape. If oculus-modeline takes data, projection, and actions in a layout, the same attributes work on the tag in markdown. One component interface, two composition surfaces.
# resources/layouts/nodes.yml
name: "Nodes — filtered graph directory"
operation: page_render
input:
args:
title: Nodes
data:
nodes:
source: oculus
endpoint: nodes
params: { sort: slug, limit: 200 }
tags:
source: oculus
endpoint: tags
layout:
type: vlayout
children:
- component: filter-bar
args:
filters:
- type: tabs
field: status
options: [live, archived, all]
- type: sort
options: [slug, -modified, -created, -view_count]
- type: az-picker
field: slug
- type: tag-filter
data: $data.tags
- component: record-list
args:
data: $data.nodes
record: oculus-modeline
projection: row
href: /node/{slug}
actions:
- [peek, node-preview, { slug: "{slug}" }]
- [archive, archive-node, { slug: "{slug}" }, post]
group-by: first-letter
expected:
components:
includes: [filter-bar, record-list, oculus-modeline]
# resources/layouts/node-detail.yml
name: "Node detail — single graph node"
operation: page_render
input:
args:
title: $params.slug
data:
node:
source: oculus
endpoint: "node/$params.slug"
params: { format: markdown, force: true }
edges:
source: oculus
endpoint: "edges/$params.slug"
layout:
type: vlayout
children:
- component: oculus-modeline
args:
data: $data.node
projection: bar
- component: markdown-content
args:
content: $data.node.content
- component: record-list
args:
data: $data.edges
record: edge-link
group-by: direction
expected:
components:
includes: [oculus-modeline, markdown-content, record-list]
# resources/layouts/cases.yml
name: "Cases — detective case directory"
operation: page_render
input:
args:
title: Cases
data:
cases:
source: tasks
endpoint: tasks
params: { is_case: true }
current:
source: tasks
endpoint: tasks/current
layout:
type: vlayout
children:
- component: task-modeline
args:
data: $data.current
projection: row
href: /case/{id}
- component: filter-bar
args:
filters:
- type: tabs
field: status
options: [active, parked, completed, closed]
- type: tag-filter
field: evidence_tags
- component: record-list
args:
data: $data.cases
record: task-modeline
projection: row
href: /case/{id}
actions:
- [park, park-current, {}, post]
- [complete, complete-current, {}, post]
group-by: status
expected:
components:
includes: [task-modeline, filter-bar, record-list]
# resources/layouts/case-detail.yml
name: "Case detail — single investigation case"
operation: page_render
input:
args:
title: Case
data:
case:
source: tasks
endpoint: "tasks/$params.id"
params: { include_notes: true }
layout:
type: vlayout
children:
- component: task-modeline
args:
data: $data.case
projection: bar
- component: markdown-content
args:
content: $data.case.description
- component: record-list
args:
data: $data.case.investigation_notes
record: note-item
group-by: none
expected:
components:
includes: [task-modeline, markdown-content, record-list]