Nested Panes
Render panes inside panes. A dashboard that contains a PersonCard, a TaskList, and a Chart — each driven by the data's @type.
The problem
A pane is a full-screen view. But real apps have nested data — a Project contains People, a Person has Tasks, Tasks have Comments. You want each piece to render itself using the right pane, wherever it appears.
resolvePane()
The shell exports resolvePane — give it a node, it finds the right pane and renders it:
import { resolvePane } from '../losos/shell.js'
// Inside a parent pane's render function:
var personNode = store.get('#person1')
var div = document.createElement('div')
container.appendChild(div)
await resolvePane(personNode, lionStore, div, rawData)
resolvePane checks three sources in order:
1. Local panes
Panes loaded via <script data-pane> tags in your HTML. The same canHandle check the shell uses for tabs:
<script type="module" data-pane src="panes/person-pane.js"></script>
<script type="module" data-pane src="panes/task-pane.js"></script>
If any local pane's canHandle returns true for the node, it renders. This is the fastest path — no network requests.
2. ui:view on the node
The data itself declares how to render it. Add a ui:view property pointing to a pane URL:
{
"@id": "#person1",
"@type": "Person",
"name": "Alice",
"ui:view": "https://example.com/panes/person-card.js"
}
The shell dynamically imports the pane from the URL, caches it, and renders. This means data can travel with its own UI — a JSON-LD document can specify exactly how it should be displayed, anywhere.
This follows the W3C UI vocabulary pattern used by the Solid OS project.
3. Registry
A type-to-pane mapping. LOSOS ships a default registry:
// losos/registry.js
export default {
'wf:Tracker': '../panes/todo-pane.js',
'ical:Vtodo': '../panes/todo-pane.js'
}
Extend it in your app:
import { registry } from './losos/shell.js'
registry['schema:Person'] = './panes/person-pane.js'
registry['schema:Event'] = './panes/event-pane.js'
When resolvePane encounters a node with @type: "schema:Person", and no local pane handles it and no ui:view is set, it checks the registry, dynamically imports the pane, caches it, and renders.
Lookup order
| Priority | Source | When to use |
|---|---|---|
| 1st | Local panes | Panes bundled with your app |
| 2nd | ui:view on node | Data that travels with its own UI |
| 3rd | Registry | Default views for common types |
First match wins. If nothing matches, resolvePane logs a warning and returns null.
Example: Dashboard with nested panes
export default {
label: 'Dashboard',
icon: '\uD83D\uDCCA',
canHandle(subject, store) {
return store.type(store.get(subject.value))?.includes('Dashboard')
},
async render(subject, lionStore, container, rawData) {
var { resolvePane } = await import('../losos/shell.js')
var { html, render } = await import('../losos/html.js')
var data = rawData
var root = data // or use a reactive store
// Render the dashboard layout
render(container, html`
<h1>${data.title}</h1>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div id="person-slot"></div>
<div id="tasks-slot"></div>
</div>
`)
// Resolve nested panes for each slot
var personNode = lionStore.get('#person1')
if (personNode) {
await resolvePane(personNode, lionStore,
container.querySelector('#person-slot'), rawData)
}
var tasksNode = lionStore.get('#tasks')
if (tasksNode) {
await resolvePane(tasksNode, lionStore,
container.querySelector('#tasks-slot'), rawData)
}
}
}
The dashboard doesn't know how to render a Person or a Task list. It creates slots and lets resolvePane find the right pane for each @type.
The linked data advantage
In React, you'd import PersonCard and TaskList by name. If the data changes shape, you rewrite the imports. In LOSOS, the data's @type drives which pane renders. Change the type, the UI follows automatically. Add a new type with a ui:view, and it renders itself — no code changes in the parent.
This is the core idea behind Tim Berners-Lee's data-driven UI vision — the data knows how to present itself.