Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/packages.html
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,19 @@ <h2>Add a CSS-only widget</h2>
<code>version check</code> refuses outside the body.
</td>
</tr>
<tr>
<td><code>x-measured</code></td>
<td>
A scalar in authored content pinned to one x-data input at an aware
instant. <code>input</code> names the widget's data input and
<code>at</code> names its required date-time attribute. The authored
words remain frozen with the version; when that source's stored
<code>updated</code> instant moves past <code>at</code>,
<code>version check</code> advises that the measurement is stale and
<code>page state</code> reports the same debt. The declaration compares
runs, not age: a source that never updates creates no warning.
</td>
</tr>
<tr>
<td><code>x-paints</code></td>
<td>
Expand Down
1 change: 1 addition & 0 deletions examples/gallery.data.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"heat-weather-gap": 38,
"atlas-worktrees": {
"tree-w-1": {
"branch": "atlas/xml-declarations",
Expand Down
12 changes: 10 additions & 2 deletions examples/gallery.html
Original file line number Diff line number Diff line change
Expand Up @@ -1569,8 +1569,16 @@ <h2>Against the weather</h2>
<p id="heat-weather-note">
Every week sits at its own average outside temperature, so a colder winter
cannot be mistaken for a leakier house. The two runs are the same house six
weeks apart: the second sits about 40 kWh a week below the first at the same
temperature, which is the draught-proofing showing up.
weeks apart: the second sits about
<lf-num
id="heat-weather-gap"
source="heat-weather-gap"
at="2026-02-01T12:00:00Z"
via="uv run analyse-heat --weather-normalise"
>40 kWh a week</lf-num
>
below the first at the same temperature, which is the draught-proofing showing
up.
</p>
<lf-chart id="heat-response" kind="dots" y="gas, kWh a week">
<pre>
Expand Down
3 changes: 3 additions & 0 deletions examples/heat-loss.data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"heat-weather-gap": 38
}
12 changes: 10 additions & 2 deletions examples/heat-loss.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,16 @@ <h2>Against the weather</h2>
<p id="heat-weather-note">
Every week sits at its own average outside temperature, so a colder winter
cannot be mistaken for a leakier house. The two runs are the same house six
weeks apart: the second sits about 40 kWh a week below the first at the same
temperature, which is the draught-proofing showing up.
weeks apart: the second sits about
<lf-num
id="heat-weather-gap"
source="heat-weather-gap"
at="2026-02-01T12:00:00Z"
via="uv run analyse-heat --weather-normalise"
>40 kWh a week</lf-num
>
below the first at the same temperature, which is the draught-proofing showing
up.
</p>
<lf-chart id="heat-response" kind="dots" y="gas, kWh a week">
<pre>
Expand Down
1 change: 1 addition & 0 deletions plugins/leaf/skills/leaf/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ The extension keys describe general behavior:
| `x-upgrade` | import this tag's module |
| `x-content` | the element contains prose, items, data, or no authored content |
| `x-inline` | the widget stands in an inline run |
| `x-measured` | authored scalar words are pinned at an instant to one live data input; checks compare that instant with the source's latest update |
| `x-says` | named attributes are visible words at declared edges |
| `x-paints` | named attributes communicate facts through paint and need a quiet spoken reading |
| `x-verbatim` | authored data must agree with the rendered words |
Expand Down
1 change: 1 addition & 0 deletions plugins/leaf/skills/leaf/assets/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@
"x-inline": "The element is set among the words around it, so the render gate holds its box to no width of its own and the rule that a block widget stands in a block slot passes over it.",
"x-language": "The attribute that names a code language, held to `$languages.names` — the one list this page colors from — so a second tag taking a language costs a declaration and no new reader.",
"x-lines": "Attributes holding 1-based line references into the nearest data body — the element's own <pre>, or its holder's (a note's `at` names a line of its code block) — which `version check` refuses outside the body.",
"x-measured": "A scalar in authored content pinned to one x-data input at an aware instant. `input` names the widget's data input and `at` names its required date-time attribute. The authored words remain frozen with the version; when that source's stored `updated` instant moves past `at`, `version check` advises that the measurement is stale and `page state` reports the same debt. The declaration compares runs, not age: a source that never updates creates no warning.",
"x-paints": "Attributes the theme renders as paint alone — a status marker's tint, an event's kind, the ring on the recommended option. The runtime speaks each to a reader listening (the value, or a flag attribute's own name), silently on screen and out of every selection, so a fact stated in ink is not silent to whoever cannot see it.",
"x-parent": "The widgets an instance may be a direct child of — an item's holder — and, read the other way, what a container is made of: a gesture a container takes on its whole box stops at what it holds, and this is where it reads which children are its own rather than things it merely contains.",
"x-refers": "Attributes whose values name another element on the page (an option's `for`), which `version check` holds to an id the version carries and the runtime renders as a reference the reader can follow.",
Expand Down
48 changes: 48 additions & 0 deletions plugins/leaf/skills/leaf/packages/default/registry.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
{
"$data": {
"contracts": {
"measured-scalar": {
"description": "The latest scalar emitted by a repeatable measurement source. Authored lf-num text freezes the value a version states; the source snapshot records whether that measurement ran again.",
"schema": {
"type": ["number", "string"]
}
}
}
},
"lf-ask": {
"description": "One complete request to the reader: the heading, context, evidence, and answer control they need to consider it as a unit. It wraps exactly one non-quoted widget whose entry declares x-awaits; that nested widget still owns the action and determines whether the request is open. Use this when the request begins before its actionable widget — most often a section's heading and introductory prose above an lf-options group — so the Asks tray names the whole region and `n`/`p` arrives at its opening rather than dropping the reader straight into the answers. Keep the ordinary semantic section around it when the surrounding document needs a section landmark. The wrapper adds no frame or caption of its own.",
"type": "object",
Expand Down Expand Up @@ -38,6 +48,44 @@
"x-verbatim": true,
"x-example": "Deploy through a <lf-gloss tip=\"A thin, end-to-end path through the real system.\">walking skeleton</lf-gloss> before parallelizing."
},
"lf-num": {
"description": "One measured scalar frozen into the sentence that states it. Write the displayed value, unit included, as the element text; bind `source` to the repeatable measurement feed and set `at` to that source snapshot's recorded `updated` instant. A later `leaf data set` on the same source makes `version check` advise that this number is stale without replacing what an older version says. `via` may preserve the human-readable command or query in markup when the source id alone is not enough provenance. Use lf-metric instead when the number is a headline KPI in a metric row, and ordinary text for a number that is not produced by a repeatable measurement.",
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]*$"
},
"source": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"at": {
"type": "string",
"format": "date-time"
},
"via": {
"type": "string",
"minLength": 1
}
},
"required": ["source", "at"],
"additionalProperties": false,
"x-content": "prose",
"x-data": {
"measurement": {
"contract": "measured-scalar",
"source": "source"
}
},
"x-inline": true,
"x-measured": {
"input": "measurement",
"at": "at"
},
"x-upgrade": false,
"x-example": "The import now takes <lf-num source=\"import-latency\" at=\"2026-08-27T09:00:00Z\" via=\"uv run bench-import\">184 ms</lf-num> at p95."
},
"lf-toc": {
"description": "A compact table of contents derived from the page's h2-h6 headings. Add one near the opening of a page whose sections are long enough that a reader benefits from seeing the route before reading it; short pages and pages with only one or two sections need no contents block. The runtime lists the headings in document order and indents deeper levels, so the headings remain the one authored outline. Existing heading ids become link targets. For an id-less heading, the widget adds a position-derived target beside it without changing the authored heading. Live links reveal headings inside disclosures or tabs, a copied link returns to the same version's section, and a standalone export keeps the same ordinary fragment links. A stable authored heading id keeps that target across versions. The generated labels are link apparatus rather than a second commentable copy of each heading, and version diffs ignore them as generated navigation. Write the element empty and give it a stable id.",
"type": "object",
Expand Down
16 changes: 16 additions & 0 deletions plugins/leaf/skills/leaf/references/page-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,22 @@ reason it holds, and the question the page is asking. A few sentences hold all
three. A section that runs longer is carrying either a structure with a shape of
its own or backing that belongs under `<details>`.

When one measured scalar belongs inside a sentence, freeze it with its provenance:

```html
The import takes <lf-num source="import-latency"
at="2026-08-27T09:00:00Z" via="uv run bench-import">184 ms</lf-num> at p95.
```

Set that source after every run with `leaf data set PAGE import-latency`, then pin
`at` to that write's `updated` instant. The element's words and `at` remain part of the
authored version; the replaceable source is only the freshness channel. If its `updated`
instant moves past `at`, `version check` advises that the pinned number needs another
look. `leaf data set` stamps `updated` at wall-clock, so an `at` naming when the
measurement itself ran is already behind the write that recorded it and reads as stale
the moment it is authored. This detects a rerun the version missed, not a measurement
that is merely old. Use one source id for one stable measurement definition.

Write for what the reader has seen, which is this conversation and the page so
far. Introduce the names a decision depends on, put evidence on the page for a
claim they could doubt, and drop the journey once the conclusion replaces it.
Expand Down
3 changes: 2 additions & 1 deletion plugins/leaf/skills/leaf/references/serving-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ leaf page state <page>
```

Read the active revision and its stamped version if any, standing decisions, open asks, each thread's
exchange, and record debt. If the
exchange, record debt, and `measurement_lag` for pinned figures whose sources have run
again. If the
state reports a live watcher, the host ends that watcher before continuing. The
successor then runs `leaf wait <page>`, whose named wait claims the page for that
session. Starting a server when the standing one is already live prints its URL
Expand Down
14 changes: 12 additions & 2 deletions plugins/leaf/skills/leaf/scripts/leaf/checking.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pathlib import Path
from typing import NamedTuple

from leaf.data import data_binding_errors, read_data_store
from leaf.data import data_binding_errors, empty_data, measurement_lag, read_data_store
from leaf.events import flocked, read_events, retractions, thread_structure
from leaf.files import list_revisions, revision_path
from leaf.passages import spoken
Expand Down Expand Up @@ -184,7 +184,9 @@ def check_source(
previous = parse_structure(previous_html)
was = spoken(previous_html, registry or {})

stored_data = empty_data()
if registry is not None:
stored_data = read_data_store(page_dir)
errors.extend(widget_errors(parser.lf_elements, registry))
errors.extend(visual_part_errors(parser.lf_elements, registry))
history = [
Expand All @@ -200,7 +202,7 @@ def check_source(
data_binding_errors(
page_dir,
registry,
read_data_store(page_dir),
stored_data,
events,
[*history, (parser.lf_elements, "index.html")],
)
Expand Down Expand Up @@ -333,6 +335,14 @@ def check_source(
current_projection, parser.by_id, now, registry or {}
)
),
*(
f"measurement behind its source: {line}"
for line in measurement_lag(
parser.lf_elements,
registry or {},
stored_data,
)
),
*unpointable_blocks(parser),
]
return SourceCheck(
Expand Down
55 changes: 54 additions & 1 deletion plugins/leaf/skills/leaf/scripts/leaf/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from .events import now_iso, read_events
from .files import list_revisions, revision_path, write_json
from .registry import is_aware_datetime, json_validator, require_registry
from .registry import aware_instant, is_aware_datetime, json_validator, require_registry
from .schema import DATA_CONTRACT_NAME, DATA_FILE, DATA_SOURCE_NAME
from .service import PageTransaction
from .structure import parse_structure
Expand Down Expand Up @@ -223,6 +223,59 @@ def data_binding_inventory(lf_elements: list, registry: dict) -> dict:
return {source: inventory[source] for source in sorted(inventory)}


def measurement_lag_entries(lf_elements: list, registry: dict, stored: dict) -> list:
"""Authored measurements whose bound source has completed a later run.

The widget declaration joins the frozen half (its timestamp attribute) to the live
half (one x-data input). Invalid attributes stay with widget validation, and an
unset source says only that no later run is known, so neither becomes advice here.
"""
entries = []
for rec in lf_elements:
entry = registry.get(rec["tag"], {})
measured = entry.get("x-measured")
if not measured:
continue
input_spec = entry.get("x-data", {}).get(measured["input"])
if not input_spec:
continue # registry validation owns the malformed declaration
source = rec["attrs"].get(input_spec["source"])
captured = rec["attrs"].get(measured["at"])
snapshot = stored["sources"].get(source) if isinstance(source, str) else None
captured_at = aware_instant(captured) if isinstance(captured, str) else None
updated_at = (
aware_instant(snapshot["updated"])
if isinstance(snapshot, dict) and isinstance(snapshot.get("updated"), str)
else None
)
if captured_at is None or updated_at is None or updated_at <= captured_at:
continue
entries.append(
{
"tag": rec["tag"],
"widget": rec["attrs"].get("id"),
"line": rec["line"],
"source": source,
"at": captured,
"updated": snapshot["updated"],
}
)
return entries


def measurement_lag(lf_elements: list, registry: dict, stored: dict) -> list[str]:
"""`measurement_lag_entries` as source-check advice lines."""
lines = []
for entry in measurement_lag_entries(lf_elements, registry, stored):
identity = f" id={entry['widget']!r}" if entry["widget"] else ""
lines.append(
f"<{entry['tag']}{identity}> (line {entry['line']}) pins source "
f"{entry['source']!r} at {entry['at']}, but that source was updated at "
f"{entry['updated']}"
)
return lines


def data_binding_errors(
page_dir: Path,
registry: dict,
Expand Down
16 changes: 11 additions & 5 deletions plugins/leaf/skills/leaf/scripts/leaf/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
from pathlib import Path

from .data import page_data_binding_inventory, read_data
from .data import measurement_lag_entries, page_data_binding_inventory, read_data
from .events import (
bare_reaction,
build_threads,
Expand Down Expand Up @@ -192,9 +192,10 @@ def _write_page_state(
gets missed. So this prints the readings the runtime derives, from the same
constructions it derives them with: the active revision's elements, the
projection of the user's standing state and the reports standing on the agent
channel, where the record lags either (`record_lag_entries`), the open asks
on the page and in threads (the banner's own count), each comment thread's
exchange,
channel, where the record lags either (`record_lag_entries`), authored
measurements whose live source has run again (`measurement_lag_entries`), the
open asks on the page and in threads (the banner's own count), each comment
thread's exchange,
and presence beside what answers for it. Computed on demand from the log,
revision, registry, and source store — no derived reading is stored, so there
is no second copy of the truth to reconcile.
Expand All @@ -219,6 +220,7 @@ def _write_page_state(
html = revision_path(page_dir, revision).read_text(encoding="utf-8")
projection, parser, spk = page_projection(html, events, registry, revision)
threads = build_threads(events, enclosing_of(spk))
stored_data = read_data(page_dir)
state = {
"page": str(page_dir),
"title": "",
Expand All @@ -233,8 +235,9 @@ def _write_page_state(
"elements": [],
"state": [],
"updates": [],
"data": read_data(page_dir),
"data": stored_data,
"data_bindings": page_data_binding_inventory(page_dir, registry, events),
"measurement_lag": [],
"asks": [],
# Whole, through the same digest a delivery carries: a session picking
# the page up is in the position this reading exists for, and a count of
Expand Down Expand Up @@ -303,6 +306,9 @@ def _write_page_state(
seats_with_agent(threads),
)
state["lag"] = record_lag_entries(projection, byid, spk, registry)
state["measurement_lag"] = measurement_lag_entries(
parser.lf_elements, registry, stored_data
)
state["asks"] += thread_asks(
events, registry, {rid for rid, t in threads.items() if t["resolved"]}
)
Expand Down
Loading
Loading