Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This book builds user interfaces the ikigai way: a view is a resource. A page is not a program that fetches data and assembles markup. It is a name the kernel resolves, composed from other names, cached like any other answer and recomputed when what it was made from changes. The same model is delivered to a browser as HTML, and later to a native window, a page running its own kernel, or a terminal, without a second renderer written against a second idea of the data.

The book teaches by building one small, real app: a kernel inspector. It shows a running kernel to the person running it: what its cache holds, which golden threads have been cut, how its spaces are arranged, what its endpoints say about themselves, and its scheduler. If you have read The ikigai Book you have met every one of those as text. source urn:kernel:cache printed the cache in Twenty minutes with the CLI, and the threads and their cuts were the subject of Golden threads in practice. Here they become pages that update while you watch.

This is the third book in the library, which lists every book with who it is for and where to start.

What you need

  • The ikigai Book’s first part, or the same ideas from somewhere else: resources, spaces, capabilities and golden threads. This book uses them and does not teach them again.
  • A browser. Every chapter in Part I runs in the page: the cells talk to a kernel compiled to WebAssembly that holds the whole inspector, the same crates the rest of the book builds.
  • Rust, for the listings and for serving the inspector yourself in Part II. Each listing is compiled and run by mdbook test whenever the book is built, so a listing that stopped matching the code would fail the build before it reached you.

How the book is laid out

Part I, the model, is about views as resources, whatever delivers them: a page composed from parts, a graph turned into a page by XSLT, the same kind of view over a JSON document and a CSV table, and data that changes while it is shown. The inspector’s own views use RDF and XSLT because the kernel publishes its introspection as graphs; a view composes whatever form its data already has, and the JSON and CSV chapters show one that never meets RDF. The model does not know whether it will end up in a browser.

Part II, delivery targets, takes the same model to a place a person sees it. Server-rendered HTML with htmx and a push stream is written; the others (GPUI, WebAssembly in the browser, WebTransport and the terminal) are reserved, each with a page saying what it will cover and what it waits on. A new target is a new thin crate and a new chapter, not a rewrite.

Part III, components, will cover the pieces you embed in a page you did not write.

Some chapters are not written yet. Their pages say so and say what they will cover; What’s new lists a chapter when it is written.

The code

The inspector is two crates in this book’s repository, split the way every target will be:

  • crates/inspector is the model and its artifacts: the stylesheets, the templates, the route-to-view names, and the one atom that holds state. It is the same crate in every target.
  • crates/inspector-htmx is a thin renderer: a host that serves the inspector over HTTP, through the same door library the cli uses.

The book’s own in-page kernel, crates/ui-book-wasm, is a third, even thinner face: the inspector’s kernel with the cli’s engine in front of it, so a cell’s line means exactly what it means at ikigai.

The rules every page follows (how a view is made, how its URL is spelled, what the door allows, what accessible means and how it is checked) were decided before any code was written. Each chapter names the ones it uses, and what they rule out.

What’s new

What has changed in Building ikigai UIs, most recent first: the date, what was added or changed, and the pages to read for it. Smaller upkeep (moving to a newer release when nothing a reader does changes, a corrected link, a typo) is not listed.

2026-10

  • 2026-10-30: The delivery targets now lead with their technology: “htmx”, “GPUI”, “WebAssembly”, “WebTransport” and “Text: in the terminal”. Two headings in the views chapters now name their subject: “Other forms of data” and “When a graph helps”.

    Pages: htmx: server-rendered HTML with push, GPUI: native, WebAssembly: in the browser, WebTransport: server-rendered, Text: in the terminal, Views from CSV: one model, two sources, Views from JSON

  • 2026-10-28: Links to the ikigai Book’s CLI tour use its new title, “Twenty minutes with the CLI”.

    Pages: Introduction, Live data and history

  • 2026-10-27: “The other books” now links Running ikigai, the operator’s book, and says the library lists all five books.

    Pages: The other books

  • 2026-10-25: “The other books” now links The ikigai Architecture too, and says the library lists all four books.

    Pages: The other books

  • 2026-10-23: Two new chapters build views over data that is not a graph: “Views from JSON” makes a table of the planets from a JSON document with compose templates and ikigai-fn’s each, and reaches into a document with ikigai-json’s jq query; “Views from CSV: one model, two sources” shows the same view over a CSV table, read as JSON through ikigai-json, each source with its own golden thread. “Graph to page with XSLT” now says why the inspector uses RDF and XSLT (the kernel already publishes its introspection as graphs) and that a view composes whatever form its data has. The inspector’s navigation gains the two planet pages, and the introduction and “The other books” link the new library of every book.

    Pages: Views from JSON, Views from CSV: one model, two sources, Graph to page with XSLT, Introduction, The other books, A page is a composed resource

  • 2026-10-22: A new book. It builds a kernel inspector, a small real app that shows a running kernel’s cache, golden threads, topology, catalog and scheduler as pages. “A page is a composed resource” builds a page from a template and the resources it is filled with, “Graph to page with XSLT” turns the kernel’s own graphs into views, “Live data and history” makes the views follow the kernel’s cuts and gives every view a URL, and “Server-rendered HTML, with htmx and push” serves it all over HTTP. The chapters of Part I run in the page.

    Pages: Introduction, A page is a composed resource, Graph to page with XSLT, Live data and history, htmx: server-rendered HTML with push

A page is a composed resource

A page in this book is a name. Ask the kernel for urn:iki:tutorial:inspect:page:cache and it answers with HTML, the way it answers any other name: it finds the door bound to the pattern, the endpoint behind the door runs, and the answer comes back as a representation with a media type. Nothing about it is special to user interfaces. What makes it a page is only that its media type is text/html and a browser will show it.

What makes it worth building this way is what the page is made from. It is not written by a function that fetches the cache and formats a string. It is a template filled with other resources: a header, a title and the view itself, each a name of its own. The kernel resolves each part, caches the whole, and remembers which parts it read, so a change to any part recomputes the page and nothing else.

A template, and what it is filled with

The smallest view in the inspector is a card that shows a note. The card’s template is itself a resource; read it as text:

<div id="note-card" class="inspect-card" hx-get="/inspect/card" hx-trigger="ikigai:cut from:body" hx-swap="outerHTML" data-ui-quiet="">
<p>$h{urn:iki:tutorial:inspect:demo:note}</p>
</div>
[computed]

Three things in it are not HTML. $h{urn:iki:tutorial:inspect:demo:note} is a marker: when the template is filled, compose (ikigai-fn’s template language) resolves the name inside the braces and splices the answer in its place. The h says how: as text, escaped for HTML, so whatever the note holds can never become markup. The others are htmx attributes, which the chapter on live data explains.

The card is the template, filled. It is bound at its own name, demo:card, by a composeOver: compose with its template fixed, so the view is a resource like any other.

<div id="note-card" class="inspect-card" hx-get="/inspect/card" hx-trigger="ikigai:cut from:body" hx-swap="outerHTML" data-ui-quiet="">
<p>Write me, then watch the cache.</p>
</div>
[computed]

Read it again, and the answer comes from the cache: nothing was recomputed, because nothing it was made from has changed.

<div id="note-card" class="inspect-card" hx-get="/inspect/card" hx-trigger="ikigai:cut from:body" hx-swap="outerHTML" data-ui-quiet="">
<p>Write me, then watch the cache.</p>
</div>
[cached]

The one atom

Almost nothing in the inspector holds state. The views are compositions, the stylesheets are artifacts, and the kernel’s readouts belong to the kernel. The note is the exception, so it is the one place the inspector has code that keeps something: an atom. Write it, with something in it that would be markup if it were not escaped:

written
[uncacheable]

The write cut the note’s golden thread. The card was cached hanging from it, so the next read of the card is computed again, from the new note, and the tags arrive as text:

<div id="note-card" class="inspect-card" hx-get="/inspect/card" hx-trigger="ikigai:cut from:body" hx-swap="outerHTML" data-ui-quiet="">
<p>A &lt;b&gt;bold&lt;/b&gt; claim</p>
</div>
[computed]

That is the whole model of a page, at its smallest: a template that is a resource, filled with resources, cached, and recomputed exactly when one of its parts is written.

The page around a view

A full page needs more than a view: a document, a stylesheet, scripts, landmarks, a skip link, a region for status messages. Every page in every ikigai app needs the same ones, so they are not the inspector’s to write. They come from ikigai-ui, which serves a shared page shell, a token stylesheet with tested themes, one first-party script, and a vendored htmx, all as resources. A host binds ikigai-ui’s space and asks it for a family of pages over its own names:

/// A kernel that serves the inspector: ikigai-ui's shared artifacts and the inspector's pages,
/// its own names, XSLT and RDF transreption, with the Turtle Meta renderer (the catalog view
/// needs it). `extra` comes FIRST, so a host's directory binding at [`DIR`] (or anything else it
/// adds) is consulted before the rest.
pub fn kernel(extra: Vec<Arc<dyn Space>>) -> Kernel {
    let mut layers = extra;
    layers.push(Arc::new(ikigai_ui::space()));
    layers.push(Arc::new(
        ikigai_ui::pages(FAMILY, BASE).expect("a valid family and base"),
    ));
    layers.push(Arc::new(space()));
    layers.push(Arc::new(ikigai_xslt::space()));
    layers.push(Arc::new(ikigai_rdf::space()));
    layers.push(Arc::new(ikigai_json::space()));
    Kernel::with_meta_renderer(Arc::new(Fallback::new(layers)), Arc::new(TurtleRenderer))
}

ikigai_ui::pages("inspect", "urn:iki:tutorial:inspect:") binds urn:iki:tutorial:inspect:page:{name}: the shell, filled with this family’s urn:iki:tutorial:inspect:header, …:title:{name} and …:fragment:{name}. The page’s title is a resource too:

A note to watch · Inspector
[computed]

and the page is the shell over the note’s fragment, which in turn includes the card:

<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="htmx-config" content='{"allowEval":false,"allowScriptTags":false,"includeIndicatorStyles":false,"h…
<title>A note to watch · Inspector</title>
<link rel="stylesheet" href="/ui/tokens.css">
<script src="/ui/shell.js"></script>
<script src="/ui/htmx" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" defer></script>
</head>
<body>
<a class="ui-skip" href="#main">Skip to content</a>
<header>
<link rel="stylesheet" href="/inspect/inspector.css">
<nav aria-label="Inspector">
<a href="/inspect/cache" hx-get="/inspect/fragment/cache" hx-target="#main" hx-push-url="/inspect/cache">Cache</a>
<a href="/inspect/threads" hx-get="/inspect/fragment/threads" hx-target="#main" hx-push-url="/inspect/threads">Threads</a>
<a href="/inspect/topology" hx-get="/inspect/fragment/topology" hx-target="#main" hx-push-url="/inspect/topology">Topology</a>
<a href="/inspect/catalog" hx-get="/inspect/fragment/catalog" hx-target="#main" hx-push-url="/inspect/catalog">Catalog</a>
<a href="/inspect/scheduler" hx-get="/inspect/fragment/scheduler" hx-target="#main" hx-push-url="/inspect/scheduler">Scheduler</a>
<a href="/inspect/note" hx-get="/inspect/fragment/note" hx-target="#main" hx-push-url="/inspect/note">Note</a>
<a href="/inspect/planets-json" hx-get="/inspect/fragment/planets-json" hx-target="#main" hx-push-url="/inspect/planets-json">Planets (JSON)</a>
<a href="/inspect/planets-csv" hx-get="/inspect/fragment/planets-csv" hx-target="#main" hx-push-url="/inspect/planets-csv">Planets (CSV)</a>
</nav>
<script src="/inspect/live.js" defer></script>
 
</header>
<div id="status" role="status" aria-live="polite"></div>
<main id="main" tabindex="-1">
<section id="view" class="inspect">
<h1>A note to watch</h1>
<p>Writing the note cuts its golden thread. Open the cache view in another tab and watch the card's entry turn <code>cut</code>.</p>
<div id="note-card" class="inspect-card" hx-get="/inspect/card" hx-trigger="ikigai:cut from:body" hx-swap="outerHTML" data-ui-quiet="">
<p>A &lt;b&gt;bold&lt;/b&gt; claim</p>
</div>
 
<form method="post" action="/inspect/note/write" hx-post="/inspect/note/write" hx-swap="none">
<label for="note-text">The note</label>
<textarea id="note-text" name="content" rows="3" maxlength="4096" required></textarea>
<button type="submit">Write it</button>
</form>
</section>
 
</main>
</body>
</html>
[computed]

Two things about that page are rules, not accidents.

The fragment and the page are separate names. fragment:note is the view alone, what htmx swaps into the page when you follow a link; page:note is the document a reload or a bookmark gets. The page composes the fragment, so the two cannot drift apart, and no URL ever answers two different things depending on who asks (UI conventions D8).

The page splices what the host says, never what the request says. The shell splices the fragment raw, as trusted markup, so which fragment it splices is fixed by the page’s own name. No argument on the request can point the shell at a different resource.

In Rust

The same reads, compiled and run by mdbook test. The capability is not root: a viewer gets the inspector’s viewer scopes, and every part of the page is resolved under them.

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate inspector;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = inspector::kernel(Vec::new());
let viewer = Capability::root().attenuate(inspector::VIEWER_SCOPES);
let read = |name: &str| {
    let rep = block_on(kernel.issue(Request::new(Verb::Source, Iri::parse(name).unwrap()), &viewer))
        .unwrap();
    String::from_utf8(rep.bytes).unwrap()
};

let card = read("urn:iki:tutorial:inspect:demo:card");
assert!(card.contains("<p>Write me, then watch the cache.</p>"));

block_on(kernel.issue(
    Request::new(Verb::Sink, Iri::parse("urn:iki:tutorial:inspect:demo:note").unwrap())
        .with_arg("content", ArgRef::Inline(b"A <b>bold</b> claim".to_vec())),
    &viewer,
))
.unwrap();
let card = read("urn:iki:tutorial:inspect:demo:card");
assert!(card.contains("<p>A &lt;b&gt;bold&lt;/b&gt; claim</p>"));

let page = read("urn:iki:tutorial:inspect:page:note");
assert!(page.starts_with("<!doctype html>"));
assert!(page.contains("<title>A note to watch · Inspector</title>"));
assert!(page.contains(&card));
}

What was built, and what it rules out

The card, its template, the note and the page are resources; only the note is code that holds state. What this rules out is the thing most web code does: Rust (or any language) assembling HTML strings per view (UI conventions D1). There is no format! building markup anywhere in the inspector, and the escaping lives in exactly one place, compose’s $h.

Next, the views whose input is a graph: Graph to page with XSLT.

Graph to page with XSLT

The card in the last chapter was filled from named parts. Most of what the inspector shows is not like that: the kernel describes its cache, its threads, its arrangement and its endpoints as graphs, and a page about a graph has to walk it. There is a standard for turning a document into another document by walking it, and ikigai uses it: XSLT. When the input is a graph the page is an XSLT transform, and when the input is named arguments the page is a compose template (UI conventions D2).

The inspector uses RDF and XSLT because of where its data comes from, not because a view needs them. The kernel already publishes its introspection as Turtle, so the shortest path from a readout to a page is a stylesheet over that graph. A view composes whatever form its data already has. D2 says what to do with data that is ALREADY a graph; it is not a rule that data must become one before it can be shown. Your data may be a JSON document from an API or a CSV export from a spreadsheet, and the two chapters after this one build views over exactly those, with no RDF in them: Views from JSON and Views from CSV: one model, two sources.

This chapter builds the threads view, then reads the four others built the same way.

The input: a readout, as a graph

Golden threads were introduced in Golden threads in practice: a cached answer hangs from the threads of what it read, and a write cuts a thread. The kernel lists the threads that have been cut at urn:kernel:threads. So that there is one to see, read the card and write the note:

<div id="note-card" class="inspect-card" hx-get="/inspect/card" hx-trigger="ikigai:cut from:body" hx-swap="outerHTML" data-ui-quiet="">
<p>Write me, then watch the cache.</p>
</div>
[computed]
written
[uncacheable]

The readout’s default face is text, for a person at a terminal. Its graph face is Turtle:

@prefix ik: <https://ikigai-rs.dev/ns#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
 
<urn:ikigai:thread:urn:iki:tutorial:inspect:demo:note> a ik:GoldenThread ;
    ik:threadName "urn:iki:tutorial:inspect:demo:note" ;
    ik:cutCount "1"^^xsd:nonNegativeInteger .
[uncacheable]

That graph is what the view is made from. Two steps stand between it and a page.

Step one: the graph a stylesheet can read

XSLT reads XML, and RDF has an XML syntax, so the first step is a transreption: the same graph, in RDF/XML. ikigai-rdf serves that as a resource, urn:rdf:transrept; the inspector does not write a serializer. It does add one thing first. A thread’s page has a URL, and building a URL means percent-encoding the thread’s name, which XSLT 1.0 cannot do. So the link arrives as data on the node it belongs to, computed once, by the shared encoder. That is graph:{view}, the one piece of the views that is code:

<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:its="http://www.w3.org/2005/11/its">
	<GoldenThread xmlns="https://ikigai-rs.dev/ns#" rdf:about="urn:ikigai:thread:urn:iki:tutorial:inspect:demo:note">
		<cutCount xmlns="https://ikigai-rs.dev/ns#" rdf:datatype="http://www.w3.org/2001/XMLSchema#nonNegativeInteger">1</cutCount>
		<threadName xmlns="https://ikigai-rs.dev/ns#">urn:iki:tutorial:inspect:demo:note</threadName>
		<fragment xmlns="urn:iki:tutorial:inspect:term#">/inspect/fragment/thread/urn%3Aiki%3Atutorial%3Ainspect%3Ademo%3Anote</fragment>
		<page xmlns="urn:iki:tutorial:inspect:term#">/inspect/thread/urn%3Aiki%3Atutorial%3Ainspect%3Ademo%3Anote</page>
	</GoldenThread>
</rdf:RDF>
[uncacheable]

It reads the kernel’s readout under the caller’s capability. A viewer who may not inspect the kernel is refused by the kernel, for the kernel’s reason; the inspector shows only what its viewer may see, and it never decides that itself.

Step two: the stylesheet

The stylesheet is an artifact, a resource the inspector serves at urn:iki:tutorial:inspect:xsl:threads. It is short enough to read whole:

<?xml version="1.0" encoding="UTF-8"?>
<!-- The threads view: every golden thread that has been cut, and how often (urn:kernel:threads). -->
<xsl:stylesheet version="1.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
    xmlns:ik="https://ikigai-rs.dev/ns#"
    xmlns:insp="urn:iki:tutorial:inspect:term#"
    exclude-result-prefixes="rdf ik insp">
<xsl:output method="html"/>
<xsl:template match="/">
<section id="view" class="inspect">
<span hidden="" data-ui-quiet="" hx-get="/inspect/fragment/threads" hx-trigger="ikigai:cut from:body" hx-target="closest section" hx-swap="outerHTML"></span>
<h1>Golden threads</h1>
<p>Every thread that has been cut, and how many times. A cached answer is valid only while every thread it hangs from still stands at the count it was stored under.</p>
<table class="inspect-table">
<caption>Cut threads, by name</caption>
<thead><tr><th scope="col">Thread</th><th scope="col">Cuts</th></tr></thead>
<tbody>
<xsl:apply-templates select="rdf:RDF/ik:GoldenThread">
<xsl:sort select="ik:threadName"/>
</xsl:apply-templates>
</tbody>
</table>
</section>
</xsl:template>
<xsl:template match="ik:GoldenThread">
<tr>
<td><a href="{insp:page}" hx-get="{insp:fragment}" hx-target="#main" hx-push-url="{insp:page}"><xsl:value-of select="ik:threadName"/></a></td>
<td><xsl:value-of select="ik:cutCount"/></td>
</tr>
</xsl:template>
</xsl:stylesheet>

Values leave it only through xsl:value-of and attribute value templates, which escape what they write. Nothing disables output escaping, and a test fails if any stylesheet ever does. Every link is a real href that works without a script, plus hx-get for the fragment and hx-push-url for the page, so htmx can swap the view in place and the address bar still names what you see.

The view is the two, composed

The fragment is a compose template whose one marker asks urn:xslt:transform for the transform of the graph by the stylesheet:

$r{urn:xslt:transform?src=urn:iki:tutorial:inspect:graph:{view}&stylesheet=urn:iki:tutorial:inspect:xsl:{view}}

and the inspector binds it at fragment:{view}, so every graph view is the same three lines of binding over a different stylesheet:

    space
        .bind(template(&format!("{BASE}graph:{{view}}")), graph_endpoint())
        .bind(
            template(&format!("{BASE}title:{{name}}")),
            titles_endpoint(),
        )
        .bind(
            Exact::new(format!("{BASE}fragment:scheduler")),
            composed(
                "urn:iki:tutorial:inspect:template:scheduler",
                "inspect-fragment-scheduler",
            ),
        )
        .bind(
            Exact::new(format!("{BASE}fragment:note")),
            composed(
                "urn:iki:tutorial:inspect:template:note",
                "inspect-fragment-note",
            ),
        )
        .bind(
            template(&format!("{BASE}fragment:{{view}}")),
            composed("urn:iki:tutorial:inspect:template:view", "inspect-fragment"),
        )

Here is the threads view, in this page’s kernel:

<section class='inspect' id='view'><span data-ui-quiet='' hidden='' hx-get='/inspect/fragment/threads' hx-swap…
[uncacheable]

The stylesheet, the template and the transform of an unchanged input are cached. The readout is not: what the kernel holds changes from one read to the next, so the view over it is recomputed every time, and it says so.

The other views

The cache, the topology and the catalog are built the same way, each with its own stylesheet over its own readout. The topology’s graph states order as RDF lists (a chain’s layers, a space’s doors), which a stylesheet cannot walk without features xrust does not run reliably, so graph:topology gives each node its place in resolution order as data, the same way it gives a thread its link.

<section class='inspect' id='view'><span data-ui-quiet='' hidden='' hx-get='/inspect/fragment/cache' hx-swap='…
[uncacheable]

A detail is a view too. fragment:thread:{name} is the thread and the entries hanging from it, and fragment:entry:{id} is one cache entry and the threads it hangs from. Their graphs keep only the focus and what is linked to it, and a detail the cache does not hold is refused as not found, with the reason, rather than shown as an empty page:

error: not found: the cache holds no entry 0123456789abcdef for you (it may have been evicted)

The scheduler, the stopgap

urn:kernel:scheduler has a text face and no Turtle face in the kernel this book runs on, so there is no graph for a stylesheet to read. Its view shows the text, escaped, and says so rather than pretending to be a graph view:

<section id="view" class="inspect">
<h1>Scheduler</h1>
<p class="inspect-stopgap">Shown as the kernel's text: <code>urn:kernel:scheduler</code> has no Turtle face in…
<pre>scheduler
  backend    single
  threads    1
</pre>
</section>
[uncacheable]

When a Turtle face arrives, the scheduler becomes one more stylesheet.

In Rust

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate inspector;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Error, Iri, Request, Verb};

let kernel = inspector::kernel(Vec::new());
let viewer = Capability::root().attenuate(inspector::VIEWER_SCOPES);
let read = |name: &str, cap: &Capability| {
    block_on(kernel.issue(Request::new(Verb::Source, Iri::parse(name).unwrap()), cap))
        .map(|rep| String::from_utf8(rep.bytes).unwrap())
};
read("urn:iki:tutorial:inspect:demo:card", &viewer).unwrap();
block_on(kernel.issue(
    Request::new(Verb::Sink, Iri::parse("urn:iki:tutorial:inspect:demo:note").unwrap())
        .with_arg("content", ArgRef::Inline(b"Threads, please".to_vec())),
    &viewer,
))
.unwrap();

let threads = read("urn:iki:tutorial:inspect:fragment:threads", &viewer).unwrap();
assert!(threads.contains("<h1>Golden threads</h1>"));
assert!(threads.contains(">urn:iki:tutorial:inspect:demo:note</a>"));

// A viewer who may write the note but not inspect the kernel: the kernel says no.
let writer = Capability::root().attenuate([inspector::CAP_NOTE]);
let refused = read("urn:iki:tutorial:inspect:fragment:threads", &writer).unwrap_err();
assert!(matches!(refused, Error::Denied(_)), "{refused}");
}

What was built, and what it rules out

Five views, four of them graph views, and one small piece of code that turns a readout into the graph a stylesheet reads. Ruled out: walking a graph in Rust to emit HTML when a stylesheet would do, and disable-output-escaping anywhere.

Next, the same kind of view over data that is not a graph: Views from JSON.

Views from JSON

Graph to page with XSLT turned the kernel’s readouts into pages with a stylesheet, because the kernel already publishes those readouts as graphs. Most data you will want to show is not a graph. It arrives as a JSON document from an HTTP API, as a CSV export from a spreadsheet, as rows from a database. This chapter and the next build a view over data like that, in the form it already has. Nothing in either chapter turns the data into RDF.

The data is a table everyone knows: the eight planets, each with its name, its kind and the length of its year in Earth days. The kernel inspector serves it twice, as a JSON document and as a CSV table, so that the next chapter can show one view over both.

The data, as it is

The JSON document is an atom, like the note in A page is a composed resource: the one kind of name in this chapter that holds state. Read it:

[
  { "name": "Mercury", "kind": "terrestrial", "year": 88 },
  { "name": "Venus", "kind": "terrestrial", "year": 225 },
  { "name": "Earth", "kind": "terrestrial", "year": 365 },
  { "name": "Mars", "kind": "terrestrial", "year": 687 },
  { "name": "Jupiter", "kind": "gas giant", "year": 4333 },
  { "name": "Saturn", "kind": "gas giant", "year": 10759 },
  { "name": "Uranus", "kind": "ice giant", "year": 30687 },
  { "name": "Neptune", "kind": "ice giant", "year": 60190 }
]
[computed]

That is the whole input: an array of records, each one an object. The view will be a table with one row per record.

A row is a template

The card in the first chapter was a template whose markers named resources. A row needs something slightly different: its values are not resources, they are the fields of one record. Compose (ikigai-fn’s template language) already has a marker for that. $h{{name}} splices the template’s argument name, escaped as text, the same way $h{urn:…} splices a resource. So the row template is three arguments in a table row:

<tr><th scope="row">$h{{name}}</th><td>$h{{kind}}</td><td>$h{{year}}</td></tr>
[computed]

Fill it with name=Earth kind=terrestrial year=365 and you have Earth’s row. What is missing is the step in between: take each record of the array and fill the template with that record’s fields as its arguments.

Once per record

Compose fills one template once. A table wants the row template filled once for every record, and compose has no “for each”: a template can splice a resource or an argument, but nothing in it repeats. Writing this chapter is how that gap was found, and it was filled where it belongs, in ikigai-fn beside compose. Since ikigai-fn 0.6.0 there is a resource for exactly this step:

urn:iki:fn:each?src=<a JSON array>&template=<a row template>: the template filled once for each element of the array, with the element’s fields as its arguments, concatenated in order.

An element’s fields reach the template only through an escaping marker ($h), so a field that holds markup arrives as text, and a template that tried to splice a field as it is would be refused. A string field is its text and a number its shortest decimal form, which is why the years arrive as 88:

<tr><th scope="row">Mercury</th><td>terrestrial</td><td>88</td></tr>
<tr><th scope="row">Venus</th><td>terrestrial</td><td>225</td></tr>
<tr><th scope="row">Earth</th><td>terrestrial</td><td>365</td></tr>
<tr><th scope="row">Mars</th><td>terrestrial</td><td>687</td></tr>
<tr><th scope="row">Jupiter</th><td>gas giant</td><td>4333</td></tr>
<tr><th scope="row">Saturn</th><td>gas giant</td><td>10759</td></tr>
<tr><th scope="row">Uranus</th><td>ice giant</td><td>30687</td></tr>
<tr><th scope="row">Neptune</th><td>ice giant</td><td>60190</td></tr>
[computed]

That is a composition, not code the inspector carries: the inspector binds ikigai-fn’s each at its conventional name and writes nothing for it.

The view

The view is one more template, with each as a marker. Read it, and notice that it never names the JSON document. It names records:{source}, so the same template serves every source:

<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:$h{{source}}</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the $h{{source}} source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
$r{urn:iki:fn:each?src=urn:iki:tutorial:inspect:records:{source}&template=urn:iki:tutorial:inspect:template:planet-row}</tbody>
</table>
</section>
[computed]

records:json is a composition too. Its template is one marker that splices the JSON document, so for this source the records ARE the document. The inspector binds the view at fragment:planets-{source}, with the same composeOver the card used, and ikigai-ui’s shell turns it into a page at page:planets-json, in the inspector’s navigation beside the graph views:

    space
        .bind(
            Exact::new(format!("{BASE}data:planets.json")),
            atom_endpoint("inspect-data-json", "a JSON document", JSON, PLANETS_JSON),
        )
        .bind(
            Exact::new(format!("{BASE}data:planets.csv")),
            atom_endpoint("inspect-data-csv", "a CSV table", CSV, PLANETS_CSV),
        )
        .bind(
            Exact::new(format!("{BASE}records:json")),
            composed(
                "urn:iki:tutorial:inspect:template:records-json",
                "inspect-records-json",
            ),
        )
        .bind(
            Exact::new(format!("{BASE}records:csv")),
            composed(
                "urn:iki:tutorial:inspect:template:records-csv",
                "inspect-records-csv",
            ),
        )
        .bind(
            template(&format!("{BASE}fragment:planets-{{source}}")),
            composed(
                "urn:iki:tutorial:inspect:template:planets",
                "inspect-fragment-planets",
            ),
        )
        .bind(Exact::new("urn:iki:fn:each"), ikigai_fn::each())

Here is the view, in this page’s kernel:

<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:json</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the json source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
<tr><th scope="row">Mercury</th><td>terrestrial</td><td>88</td></tr>
<tr><th scope="row">Venus</th><td>terrestrial</td><td>225</td></tr>
<tr><th scope="row">Earth</th><td>terrestrial</td><td>365</td></tr>
<tr><th scope="row">Mars</th><td>terrestrial</td><td>687</td></tr>
<tr><th scope="row">Jupiter</th><td>gas giant</td><td>4333</td></tr>
<tr><th scope="row">Saturn</th><td>gas giant</td><td>10759</td></tr>
<tr><th scope="row">Uranus</th><td>ice giant</td><td>30687</td></tr>
<tr><th scope="row">Neptune</th><td>ice giant</td><td>60190</td></tr>
</tbody>
</table>
</section>
[computed]

Every part of it is cached, so asking again computes nothing:

<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:json</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the json source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
<tr><th scope="row">Mercury</th><td>terrestrial</td><td>88</td></tr>
<tr><th scope="row">Venus</th><td>terrestrial</td><td>225</td></tr>
<tr><th scope="row">Earth</th><td>terrestrial</td><td>365</td></tr>
<tr><th scope="row">Mars</th><td>terrestrial</td><td>687</td></tr>
<tr><th scope="row">Jupiter</th><td>gas giant</td><td>4333</td></tr>
<tr><th scope="row">Saturn</th><td>gas giant</td><td>10759</td></tr>
<tr><th scope="row">Uranus</th><td>ice giant</td><td>30687</td></tr>
<tr><th scope="row">Neptune</th><td>ice giant</td><td>60190</td></tr>
</tbody>
</table>
</section>
[cached]

A write is a cut, here too

The JSON document is an atom, so writing it cuts its golden thread, and everything made from it is recomputed on the next read: the records, each row, the view. Replace it with a one-record document:

written
[uncacheable]
<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:json</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the json source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
<tr><th scope="row">Ceres</th><td>dwarf planet</td><td>1682</td></tr>
</tbody>
</table>
</section>
[computed]

Every value reached the page through $h, so a record whose name holds markup shows the markup as text, as the note did. A document that is not an array of objects fails the view with the parser’s reason, and never renders half a table.

Reaching into a larger document

The planets document is the array itself. A real API answer usually is not: the array sits inside an envelope, {"count": 8, "planets": [ … ]}, and the view wants .planets. Compose cannot reach into a JSON document by itself. ikigai-json can: urn:json:query runs a jq program (through jaq, sandboxed and bounded) over a JSON resource. Ask it for the first record’s name in the document you just wrote:

Ceres
[computed]

So records:json for an envelope is one marker, $r{urn:json:query?src=urn:…:planets.json&q=.planets}, and nothing else in this chapter changes. ikigai-fn 0.6.0 has a marker for the other common case: $j{<src> <program>} splices the result of a jq program as escaped text, so a template can show one field of a document ($j{urn:…:planets.json .[2].name}) without a resource of its own.

⚠ If you edit that cell, keep the program small. The sandbox bounds what a program may do, and in a native kernel a program past a bound is refused with a reason; in this page’s WebAssembly kernel the refusal stops the kernel instead, and the page has to be reloaded.

When a graph helps

JSON can become a graph. ikigai-jsonld reads JSON-LD, and a JSON-LD context maps plain JSON’s keys to vocabulary terms, after which everything in the XSLT chapter applies, and SPARQL too. That is worth doing when you want what a graph gives you: joins with other graphs, shapes to validate against, a query language. It is not the price of a view. The table above needed none of it.

What was built, and what it rules out

A view over a JSON document: an atom, two templates and three compositions (urn:iki:fn:each, the records, the view), and no code of the inspector’s own beyond the atom. Ruled out: converting the data to another model before it can be shown, and parsing JSON by hand.

Next, the same view from a CSV table: Views from CSV: one model, two sources.

Views from CSV: one model, two sources

Views from JSON built a table of the planets over a JSON document, and its view template named records:{source} instead of the document. This chapter supplies a second source, a CSV table of the same planets, and shows the view that does not care which one it is given.

The data, as it is

The CSV table is an atom of its own, with a header row and one row per planet:

name,kind,year
Mercury,terrestrial,88
Venus,terrestrial,225
Earth,terrestrial,365
Mars,terrestrial,687
Jupiter,gas giant,4333
Saturn,gas giant,10759
Uranus,ice giant,30687
Neptune,ice giant,60190
[computed]

One model

The view’s model is the one the JSON chapter used: an array of records, each a set of named fields. A JSON document of objects already is one. A CSV table becomes one by a transreption that changes the form and not the data: one record per row, keyed by the header. That is a conversion between two representations of the same thing, which is exactly what ikigai’s transreptors are for. The one needed takes a CSV table and gives a JSON array of objects keyed by its header row.

ikigai-json has exactly that: urn:json:from-csv, registered as the kernel’s text/csv → application/json transreptor, which reads the table with the csv crate (RFC 4180, so a quoted field keeps its commas). You rarely name it. urn:json:query runs a jq program over a JSON resource, and given a resource that is not JSON it plans the kernel’s transreption to JSON first, so the identity program . over the CSV atom is the table as records:

[{"name":"Mercury","kind":"terrestrial","year":"88"},{"name":"Venus","kind":"terrestrial","year":"225"},{"name":"Earth","kind":"terrestrial","year":"365"},{"name":"Mars","kind":"terrestrial","year":"687"},{"name":"Jupiter","kind":"gas giant","year":"4333"},{"name":"Saturn","kind":"gas giant","year":"10759"},{"name":"Uranus","kind":"ice giant","year":"30687"},{"name":"Neptune","kind":"ice giant","year":"60190"}]
[computed]

Every value is a string, because CSV has no types: the years are "88", where the JSON document had 88. The row template splices each as text either way, so the rows come out the same.

The kernel does not turn one representation into another just because a reader asks for it with as= (ledger #2153), which is why the conversion is spelled as a query rather than as source …:planets.csv as=application/json.

Two sources, side by side

Each source is one template, and the two are the only place the view’s sources differ. The CSV source’s records are that query over the table:

$r{urn:json:query?src=urn:iki:tutorial:inspect:data:planets.csv&q=.}
[computed]

and the JSON source’s are the document itself:

$r{urn:iki:tutorial:inspect:data:planets.json}
[computed]

The view over the CSV table is the same template:planets, filled with source=csv:

<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:csv</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the csv source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
<tr><th scope="row">Mercury</th><td>terrestrial</td><td>88</td></tr>
<tr><th scope="row">Venus</th><td>terrestrial</td><td>225</td></tr>
<tr><th scope="row">Earth</th><td>terrestrial</td><td>365</td></tr>
<tr><th scope="row">Mars</th><td>terrestrial</td><td>687</td></tr>
<tr><th scope="row">Jupiter</th><td>gas giant</td><td>4333</td></tr>
<tr><th scope="row">Saturn</th><td>gas giant</td><td>10759</td></tr>
<tr><th scope="row">Uranus</th><td>ice giant</td><td>30687</td></tr>
<tr><th scope="row">Neptune</th><td>ice giant</td><td>60190</td></tr>
</tbody>
</table>
</section>
[computed]

and the view over the JSON document, with source=json:

<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:json</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the json source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
<tr><th scope="row">Mercury</th><td>terrestrial</td><td>88</td></tr>
<tr><th scope="row">Venus</th><td>terrestrial</td><td>225</td></tr>
<tr><th scope="row">Earth</th><td>terrestrial</td><td>365</td></tr>
<tr><th scope="row">Mars</th><td>terrestrial</td><td>687</td></tr>
<tr><th scope="row">Jupiter</th><td>gas giant</td><td>4333</td></tr>
<tr><th scope="row">Saturn</th><td>gas giant</td><td>10759</td></tr>
<tr><th scope="row">Uranus</th><td>ice giant</td><td>30687</td></tr>
<tr><th scope="row">Neptune</th><td>ice giant</td><td>60190</td></tr>
</tbody>
</table>
</section>
[computed]

The two tables are the same, row for row. Only the sentence above each one names its source, because the template prints its source argument there. The inspector’s navigation has both pages, Planets (JSON) and Planets (CSV), so a browser shows the same thing.

Two sources, two threads

The views share their templates, but each hangs from its own source’s golden thread. Write the JSON document, as the last chapter did:

written
[uncacheable]

The CSV view was read from nothing that write touched, so it is still cached:

<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:csv</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the csv source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
<tr><th scope="row">Mercury</th><td>terrestrial</td><td>88</td></tr>
<tr><th scope="row">Venus</th><td>terrestrial</td><td>225</td></tr>
<tr><th scope="row">Earth</th><td>terrestrial</td><td>365</td></tr>
<tr><th scope="row">Mars</th><td>terrestrial</td><td>687</td></tr>
<tr><th scope="row">Jupiter</th><td>gas giant</td><td>4333</td></tr>
<tr><th scope="row">Saturn</th><td>gas giant</td><td>10759</td></tr>
<tr><th scope="row">Uranus</th><td>ice giant</td><td>30687</td></tr>
<tr><th scope="row">Neptune</th><td>ice giant</td><td>60190</td></tr>
</tbody>
</table>
</section>
[cached]

and the JSON view is recomputed from the new document:

<section id="view" class="inspect">
<h1>Planets</h1>
<p>One view, read from <code>urn:iki:tutorial:inspect:records:json</code>. The view does not know which source it was given: the same two templates show the JSON document and the CSV table.</p>
<table class="inspect-table">
<caption>The eight planets, from the json source</caption>
<thead><tr><th scope="col">Planet</th><th scope="col">Kind</th><th scope="col">Orbit (Earth days)</th></tr></thead>
<tbody>
<tr><th scope="row">Ceres</th><td>dwarf planet</td><td>1682</td></tr>
</tbody>
</table>
</section>
[computed]

A write to the CSV table works the same way the other way round; the inspector’s tests write one with a quoted comma and a field that holds markup, and check that the comma stays in its field, the markup arrives as text and the JSON view does not move.

No stopgaps left

While these two chapters were being written, the iteration and the CSV conversion were each a labeled stopgap in the inspector, shaped like the resources this chapter needed and named stopgap: so nobody mistook them for the design. Both gaps were filled where they belong, with the same arguments: urn:iki:fn:each in ikigai-fn 0.6.0 and urn:json:query with its CSV transreptor in ikigai-json 0.1.0. Replacing them changed two template lines and no output. The only code the inspector has for these views is its two atoms.

Other forms of data

JSON and CSV are two forms of many. The same pattern covers the others: find the model the view needs, and the smallest step from the form the data has to that model.

  • XML needs no step at all: an XSLT stylesheet reads it directly, as in the XSLT chapter, with no RDF anywhere.
  • SPARQL results already have a JSON form (application/sparql-results+json); with urn:json:query to pick out the bindings, a query’s answer is an array a row template can be filled from.
  • Plain lines of text, a log or a listing, have ikigai-text (grep, sort, head and the rest, as resources), which a view can splice as they are.

What was built, and what it rules out

One view and one row template over two sources, a CSV table and a JSON document, each source one template naming where its records come from, and each with its own golden thread. Ruled out: a second view for a second format, and a model the data has to be converted into before it can be shown.

Next: Live data and history.

Live data and history

A page about a cache is out of date the moment something is written. The usual answer is to ask again every second. ikigai has a better one, because the kernel already knows exactly when an answer goes stale: that is what golden threads are for. This chapter makes the inspector’s views follow the kernel as it changes, and makes every view a URL that Back returns to.

What a write does to the cache

In this page’s kernel, read the card, then look at the cache’s text face, the one Twenty minutes with the CLI printed:

<div id="note-card" class="inspect-card" hx-get="/inspect/card" hx-trigger="ikigai:cut from:body" hx-swap="outerHTML" data-ui-quiet="">
<p>Write me, then watch the cache.</p>
</div>
[computed]
cache
  entries  3 / 4096 (0 stale)
  size     402 B / 64.0 MB
  urn:iki:tutorial:inspect:demo:card      live     text/html; charset=utf-8      182 B  4 threads   root
  urn:iki:tutorial:inspect:demo:note      live     text/plain; charset=utf-8       31 B  1 thread    root
  urn:iki:tutorial:inspect:template:card  live     text/html; charset=utf-8      189 B  2 threads   root
[uncacheable]

The card and the note are both live: a read would be served from the cache. Now write the note:

written
[uncacheable]
cache
  entries  3 / 4096 (2 stale)
  size     402 B / 64.0 MB
  urn:iki:tutorial:inspect:demo:card      cut      text/html; charset=utf-8      182 B  4 threads   root
  urn:iki:tutorial:inspect:demo:note      cut      text/plain; charset=utf-8       31 B  1 thread    root
  urn:iki:tutorial:inspect:template:card  live     text/html; charset=utf-8      189 B  2 threads   root
[uncacheable]

Both entries are cut: still resident, but stale, so the next read recomputes them. The cut happened at one instant, for one reason, to a known set of entries. A page showing either of them does not need to guess when to look again; it needs to be told.

Being told: the push stream

The cli’s HTTP door already tells. Spreadsheet, VII: push built on it: one GET of the door’s push path answers text/event-stream, and every cut that invalidated something the page’s own reads rest on arrives as one cut event. It is heard under the same capability the page’s reads were made with, so a viewer hears only cuts of what that viewer cached, and nobody else’s (UI conventions D9).

The inspector’s part is small. A script, urn:iki:tutorial:inspect:live.js, opens the stream and turns every cut into one event on the page:

// The inspector's push listener (UI conventions D9), until ikigai-ui's shell script carries it.
//
// One EventSource on the door's push stream, under the page's own capability. Every `cut` (and
// every `resync`, which means cuts were missed) is announced to the page as one `ikigai:cut`
// event on <body>; a view that shows changing data listens for it with
// `hx-trigger="ikigai:cut from:body"` and refetches itself, quietly ([data-ui-quiet]), so the
// status region never repeats it. Nothing is parsed as markup and nothing is evaluated.
(function () {
  "use strict";
  if (!window.EventSource) return;
  var source = new EventSource("/_ikigai/push");
  function announce() {
    document.body.dispatchEvent(new CustomEvent("ikigai:cut"));
  }
  source.addEventListener("cut", announce);
  source.addEventListener("resync", announce);
})();

and a view that shows changing data refetches itself when it hears that event. The cache view’s stylesheet puts this at the top of the view:

<span hidden="" data-ui-quiet="" hx-get="/inspect/fragment/cache"
      hx-trigger="ikigai:cut from:body" hx-target="closest section" hx-swap="outerHTML"></span>

htmx does the rest: on a cut it asks for the fragment again and swaps the whole view in place. The row for the card turns cut while you watch, and a moment later, when the card is read again, live. The note’s card listens the same way, so writing the note in one tab updates the card in every tab that shows it.

Three choices in that line are rules:

  • data-ui-quiet. ikigai-ui’s shell script announces the result of what you did in the page’s status region. A view refreshing itself is not something you did, so it is quiet: it never writes the status region and never moves your focus.
  • The trigger is a cut, not a clock. Polling a cacheable fragment on a timer is ruled out; the door knows when to refetch, and an unchanged fragment would answer 304 anyway.
  • The fragment is fetched, not the page. The page composes the fragment, so refreshing the fragment is refreshing exactly what changed.

⚠ live.js is a stopgap. The shared shell script is meant to carry this mapping itself (UI conventions D14: one first-party script per page), and ikigai-ui 0.1.0 does not yet, so the inspector serves its own and says so.

Every view is a URL

Live views are only half of a page that changes. The other half is history: following a link inside the inspector swaps the view in place, and Back must still work, and a reload or a link someone pastes must land on the same view.

So every view has a page URL and a fragment URL, and every link carries both:

<a href="/inspect/threads" hx-get="/inspect/fragment/threads"
   hx-target="#main" hx-push-url="/inspect/threads">Threads</a>

With a script, htmx fetches the fragment, swaps it into <main>, and pushes the page’s URL onto the history; ikigai-ui’s shell script then moves focus to the new view’s heading, so a keyboard or screen reader user lands where the content changed. Without one, the href is an ordinary link to the whole page. A reload, a bookmark and a history miss all fetch the page, which composes the same fragment, so there is no state a reload can lose (UI conventions D10).

A detail is a view with a URL like any other. Each thread’s link carries its name, percent-encoded, as one path segment:

<section class='inspect' id='view'><span data-ui-quiet='' hidden='' hx-get='/inspect/fragment/thread/urn%3Aiki…
[uncacheable]

Why it is cheap

A push stream rides the cache the reads already built. The door registers one listener per open page, in that page’s cache partition, and the kernel tells it about a cut only when an entry that page’s reads made depends on the thread that was cut. Nothing polls. The page asks again only when its answer is known to have changed.

In Rust

The cut, seen through the kernel’s own readout, compiled and run by mdbook test:

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate inspector;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = inspector::kernel(Vec::new());
let viewer = Capability::root().attenuate(inspector::VIEWER_SCOPES);
let read = |name: &str| {
    let rep = block_on(kernel.issue(Request::new(Verb::Source, Iri::parse(name).unwrap()), &viewer))
        .unwrap();
    String::from_utf8(rep.bytes).unwrap()
};
let card_row = |cache: &str| {
    let at = cache.find(">urn:iki:tutorial:inspect:demo:card</a>").unwrap();
    cache[cache[..at].rfind("<tr").unwrap()..at].to_string()
};

read("urn:iki:tutorial:inspect:demo:card");
assert!(card_row(&read("urn:iki:tutorial:inspect:fragment:cache")).contains("state-live"));

block_on(kernel.issue(
    Request::new(Verb::Sink, Iri::parse("urn:iki:tutorial:inspect:demo:note").unwrap())
        .with_arg("content", ArgRef::Inline(b"Watch this".to_vec())),
    &viewer,
))
.unwrap();
assert!(card_row(&read("urn:iki:tutorial:inspect:fragment:cache")).contains("state-cut"));
}

The stream itself is the door’s, so its test is the door’s too: crates/inspector-htmx starts the real binary, opens /_ikigai/push, writes the note through the door, and reads the cut event that names the card.

In this page, and what is next for it

The cells above run in this page’s own kernel, so they show the cache changing as text. This page does not yet show the cache view rendered and changing in place. Nothing in the kernel stops it: a kernel compiled to WebAssembly can listen for its own cuts, and Spreadsheet, VII: push does exactly that in its page. What is missing is this book’s half: its kernel does not export a listener to the page yet, and no script here renders a view’s HTML in place of a cell’s text. That is the next step for this book’s kernel; until then, htmx: server-rendered HTML with push runs the real thing.

One model, many faces

This chapter is not written yet.

It will cover transreption for user interfaces: the thing a view shows is a resource with one model, and each delivery is a face of it reached by transreption, text/html for a browser, text/plain for a terminal, Turtle or JSON-LD for an agent or a dashboard. The precedent is already in front of you: every kernel readout the inspector shows has a text face (the one The ikigai Book prints) and a Turtle face (the one the inspector’s stylesheets read). The chapter will make the inspector’s own views answer the same way, and show a page linking its other faces with rel=alternate.

It waits on the inspector’s text faces, which are the terminal target’s first step.

Artifacts: from a directory or compiled in

This chapter is not written yet.

It will cover the inspector’s artifacts (its stylesheets, templates, script and stylesheet) as resources delivered two ways under the same names: compiled into a binary, or read from a directory a host binds, file by file, with the directory winning per artifact. The code is already there and tested: crates/inspector reads urn:iki:tutorial:inspect:dir:{file} first and falls back to its compiled-in bytes only on that file’s absence, and a test renders both deliveries and compares the bytes. cargo run -p inspector-htmx -- --artifacts DIR runs it.

The chapter will also cover what a directory may not do (change the door’s CSP or headers), and hot reload: a write to an artifact cuts its golden thread, so every page over it recomputes. It waits on a file watcher for changes made outside the kernel; today the directory is read on every request instead, which is correct and not cached.

Themes and the contrast floor

This chapter is not written yet.

It will cover ikigai-ui’s one token set: the custom properties every page and component uses, in four palettes (light, dark, high contrast and auto, which follows the system and selects high contrast when the system asks for more contrast), the chooser that works with no script, and the contrast floor that a test holds every pair of colors to. The inspector already uses it: its own stylesheet names no color, and its tests say so.

Accessibility, done and not bolted on

This chapter is not written yet.

It will cover WCAG 2.2 AA as the floor, and the parts of it that are behavior and so belong to the shared shell: landmarks and a skip link, one status region and what may speak in it, focus after a swap, keyboard paths, target sizes and reduced motion. And how each is checked rather than claimed: axe in every theme and media setting, and browser checks for what axe cannot see. htmx: server-rendered HTML with push lists the checks the inspector runs today.

Security defaults

This chapter is not written yet.

It will cover the door’s defaults and why each exists: the page CSP with no inline script or style, the Origin check on every unsafe method, cookies that carry authority only for their own origin, HTML the door did not write served so it cannot run, one escaping rule set and one way to put a value in a URL. The inspector runs behind these already (htmx: server-rendered HTML with push); this chapter will say what each one stops.

htmx: server-rendered HTML with push

Part I built the inspector as resources and never said where they would be shown. This chapter puts them in a browser: a host that serves the inspector’s pages over HTTP, with htmx swapping views in place and the push stream keeping them current. It is the first delivery target, and it is deliberately thin. Everything a page shows is the inspector crate’s; this crate adds only what a door owns.

If you played the board in Tic-tac-toe, V: a board you can play, you have seen the pattern: htmx markup served as a resource, and a door that maps a URL to a name. The inspector follows the same pattern, with the rules this book has collected.

Running it

From a checkout of this book’s repository:

cargo run -p inspector-htmx

It prints the address it is serving on (inspector: http://127.0.0.1:8071/); open it. The cache view is the front page. Open the note in a second tab, write it, and watch the card’s row in the first tab turn cut. Follow a thread’s link, then press Back.

--http 127.0.0.1:0 picks a free port and prints it. --artifacts DIR serves the inspector’s artifacts from a directory instead of the compiled-in copies: copy crates/inspector/artifacts somewhere, change cache.xsl, and reload. The next chapter of Part I to be written, Artifacts: from a directory or compiled in, is about that.

What the door owns

The route table. Each page and each fragment has a URL, ikigai-ui’s shared artifacts are at /ui/{name}, and nothing else is public: the table is the whole surface (routes_only), so the kernel’s own names are not reachable by accident.

/// The route table: every URL this door answers, and the resource behind it. First match
/// wins, so the fixed paths come before the patterns that would also fit them.
pub fn routes() -> RouteTable {
    const B: &str = "urn:iki:tutorial:inspect:";
    RouteTable::new(vec![
        route("/", &format!("{B}page:cache")),
        route("/ui/{name}", "urn:iki:ui:{name}"),
        route("/inspect/live.js", &format!("{B}live.js")),
        route("/inspect/inspector.css", &format!("{B}inspector.css")),
        route("/inspect/card", &format!("{B}demo:card")),
        route("/inspect/note/write", &format!("{B}demo:note")),
        route("/inspect/entry/{id}", &format!("{B}page:entry:{{id}}")),
        route(
            "/inspect/thread/{+name}",
            &format!("{B}page:thread:{{+name}}"),
        ),
        route(
            "/inspect/fragment/entry/{id}",
            &format!("{B}fragment:entry:{{id}}"),
        ),
        route(
            "/inspect/fragment/thread/{+name}",
            &format!("{B}fragment:thread:{{+name}}"),
        ),
        route("/inspect/fragment/{view}", &format!("{B}fragment:{{view}}")),
        route("/inspect/{view}", &format!("{B}page:{{view}}")),
    ])
}

A thread’s name is an IRI, so its route takes the segment raw ({+name}) and its link carries it percent-encoded. Every other variable is strict: a value with a : or a / in it is refused before it can name some other resource.

The door’s defaults. EdgeConfig::html_door is the cli door library’s one call for an HTML door: a page CSP that allows scripts and styles from this origin only, never inline; an Origin check that refuses any unsafe method (a form’s POST) whose Origin is not this door’s; and the security headers (nosniff, no referrer, Cross-Origin-Opener-Policy). An artifact can change what a page says, never what the door allows.

Who is asking. Every request is issued under the viewer’s capability, never root: inspect the kernel, hear its cuts, write the note. A door that grants less shows less, and the kernel, not the inspector, enforces it.

The push stream at /_ikigai/push, which the inspector’s live.js listens to.

In Rust

The door, started in this process on a free port and asked for the front page, compiled and run by mdbook test:

#![allow(unused)]
fn main() {
extern crate inspector_htmx;
extern crate tokio;
use std::io::{Read, Write};

let runtime = tokio::runtime::Runtime::new().unwrap();
let listener = runtime.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")).unwrap();
let addr = listener.local_addr().unwrap();
runtime.spawn(inspector_htmx::serve(listener, None));

let mut c = std::net::TcpStream::connect(addr).unwrap();
write!(c, "GET / HTTP/1.1\r\nHost: {addr}\r\n\r\n").unwrap();
let mut answer = String::new();
c.read_to_string(&mut answer).unwrap();
assert!(answer.starts_with("HTTP/1.1 200"));
assert!(answer.contains("script-src 'self'; style-src 'self'"));
assert!(answer.contains("<title>Cache · Inspector</title>"));
}

How it is checked

  • Snapshots. Every page is held to a file in the repository (ikigai-ui’s test kit), so a change to a page is a change someone reviews.
  • axe, through ikigai-ui’s reusable workflow: every page, in every theme, under light, dark and more-contrast media, served with the door’s CSP, with no WCAG 2.2 A or AA violation, page error or console error.
  • In a real browser (a11y/inspector.mjs): every page at 320 pixels wide without sideways scrolling; the first Tab reaches the skip link and the skip link reaches the view; a nav link swaps the view, pushes its URL and moves focus to the new heading; Back restores the view; and a cut refetches the view without moving focus or speaking in the status region.
  • The door itself (crates/inspector-htmx/tests): the real binary, every route, a write from another origin refused, and a write from this one heard on the push stream.

What it waits on

The form that writes the note works without a script, but answers a bare line of text: redirecting to the note’s page after a write is not something the door library does for a plain form yet. And the push mapping lives in the inspector’s own live.js until ikigai-ui’s shell script carries it.

GPUI: native

This chapter is not written yet.

It will deliver the inspector as a native window, with GPUI rendering the same model the HTML target renders: a new thin crate beside inspector-htmx, not a change to inspector.

It waits on the research into rendering an ikigai view model natively, and on the inspector’s model having a face a native renderer can read without parsing HTML.

WebAssembly: in the browser

This chapter is not written yet.

It will run the inspector’s kernel in the page itself, as this book’s own cells already do, and render its views there rather than print them. The precedent is The ikigai Book’s in-page kernel, which plays tic-tac-toe and recalculates a spreadsheet in the page, and listens for its own cuts.

It waits on two pieces of this book’s kernel (crates/ui-book-wasm): a listener the page can wait on, and a script that renders a view’s HTML in place, as Live data and history says.

WebTransport: server-rendered

This chapter is not written yet.

It will deliver the same server-rendered views over WebTransport instead of HTTP requests: one session per page, the kernel’s answers and its cuts on streams of that session. The precedent is the semantic CMS’s reading room, which already serves a kernel to its pages this way.

It waits on that transport being available to a host as a library, the way the HTTP door is.

Text: in the terminal

This chapter is not written yet.

It will deliver the inspector to a terminal: the same views, as text. The kernel’s readouts already have a text face, the one The ikigai Book prints at the REPL, so the terminal target starts as the text face of each of the inspector’s views, reached by transreption (One model, many faces).

It waits on those text faces.

Components across targets

This part is not written yet.

It will cover the ikigai components: custom elements for the things worth embedding in a page you did not write (a ledger, an item, a layer, a resource card, a refusal, the theme chooser), each rendered on the server and working as a link with no script, themed through the token set, describing the resource it shows, and fed with the page’s authority or a scoped, read-only token. It will show a component built once and used from more than one delivery target.

The other books

This book builds on The ikigai Book, which teaches resources, spaces, capabilities and golden threads from the first endpoint up, and is written beside The gonk Book, the work ledger and review host built from the same pieces, and The ikigai Architecture, which shows how those pieces fit. Running ikigai is the operator’s book: the ikigai binary, and what it takes to serve and run kernels for real.

The library lists all five, with who each is for, what to read first and where to start.

The books are built, tested and published together, from one repository: https://github.com/ikigai-rs/ikigai-tutorial.