Wanderland

Lantern Component Framework

Lantern Component Framework

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.

Primitives

Three layers. Layout containers, mid-level components, and one low-level primitive.

Layout Containers

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: { ... }

Mid-Level Components

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

Low-Level Primitive

One: oculus-button. Everything else is mid-level or higher.

Modeline Projections

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:

row

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.

card

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.

bar

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.

panel

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                                                            │
  └─────────────────────────────────────────────────────────────────────────┘

Projection in Layout YAML

# 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

Attribute Binding

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.

oculus-button

The sole low-level primitive. A (label, path) tuple rendered as a button. Can do three things:

1. Navigate to a path

- [view, /node/{slug}]

Renders as an <a> tag. Clicking navigates.

2. Call a named route

- [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.

3. Execute an inline chain

- 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.

Constraint

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.

In Markdown

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.

In Layout Slots

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}"

Named Routes

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.

Chain Execution

When an oculus-button defines an inline chain, the engine:

This 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 Resources

Layout definitions live as YAML files in lantern/resources/layouts/. Each file follows the scenario shape.

The Triple Use

Same file, three purposes:

Shape

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]

Route Config

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

Page Render Pipeline

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.

Web Component Hydration

Existing web components (``, ``, ``, ``, ``, ``, ``, ``) hydrate on the client as they do now. The server emits their tags in the HTML. Nothing changes for them. `` is the new addition. Server-renders as `` or `

`. Client enhances for chain execution, confirmation dialogs, optimistic UI. Modeline projections server-render as full HTML. The `` and `` web components enhance bar/panel interactions (expand/collapse, clipboard, mutation POSTs). Row and card projections are static server HTML — no client enhancement needed. ## Content Expansion Components embedded in markdown content follow the same shape as layout components: type + args. The tag name is the type. Attributes are the args. The server resolves them through the same boundary system. ```html[id=content-expansion-example]


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.

Design Constraint

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.

Projections — Full Page Examples

Nodes Page

# 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]

Node Detail

# 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]

Cases Page

# 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]

Case Detail

# 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]