diff --git a/docs/packages.html b/docs/packages.html index 060afabfe..805f8288c 100644 --- a/docs/packages.html +++ b/docs/packages.html @@ -433,6 +433,19 @@

Add a CSS-only widget

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 diff --git a/examples/gallery.data.json b/examples/gallery.data.json index 66d295e70..e948040ab 100644 --- a/examples/gallery.data.json +++ b/examples/gallery.data.json @@ -1,4 +1,5 @@ { + "heat-weather-gap": 38, "atlas-worktrees": { "tree-w-1": { "branch": "atlas/xml-declarations", diff --git a/examples/gallery.html b/examples/gallery.html index 0b34b5fef..32aa3c87e 100644 --- a/examples/gallery.html +++ b/examples/gallery.html @@ -1569,8 +1569,16 @@

Against the weather

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 + 40 kWh a week + below the first at the same temperature, which is the draught-proofing showing + up.

diff --git a/examples/heat-loss.data.json b/examples/heat-loss.data.json
new file mode 100644
index 000000000..7cfe7345b
--- /dev/null
+++ b/examples/heat-loss.data.json
@@ -0,0 +1,3 @@
+{
+  "heat-weather-gap": 38
+}
diff --git a/examples/heat-loss.html b/examples/heat-loss.html
index e52346dc3..a6abdc98e 100644
--- a/examples/heat-loss.html
+++ b/examples/heat-loss.html
@@ -112,8 +112,16 @@ 

Against the weather

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 + 40 kWh a week + below the first at the same temperature, which is the draught-proofing showing + up.

diff --git a/plugins/leaf/skills/leaf/CLAUDE.md b/plugins/leaf/skills/leaf/CLAUDE.md
index 8605476d1..0eaaf01bb 100644
--- a/plugins/leaf/skills/leaf/CLAUDE.md
+++ b/plugins/leaf/skills/leaf/CLAUDE.md
@@ -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 |
diff --git a/plugins/leaf/skills/leaf/assets/registry.json b/plugins/leaf/skills/leaf/assets/registry.json
index c32174cf3..fdc759c62 100644
--- a/plugins/leaf/skills/leaf/assets/registry.json
+++ b/plugins/leaf/skills/leaf/assets/registry.json
@@ -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 
, 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.",
diff --git a/plugins/leaf/skills/leaf/packages/default/registry.json b/plugins/leaf/skills/leaf/packages/default/registry.json
index d639f8a1f..c656e3bac 100644
--- a/plugins/leaf/skills/leaf/packages/default/registry.json
+++ b/plugins/leaf/skills/leaf/packages/default/registry.json
@@ -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",
@@ -38,6 +48,44 @@
     "x-verbatim": true,
     "x-example": "Deploy through a walking skeleton 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 184 ms 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",
diff --git a/plugins/leaf/skills/leaf/references/page-authoring.md b/plugins/leaf/skills/leaf/references/page-authoring.md
index c4db3aef9..970f7ce09 100644
--- a/plugins/leaf/skills/leaf/references/page-authoring.md
+++ b/plugins/leaf/skills/leaf/references/page-authoring.md
@@ -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 `
`. +When one measured scalar belongs inside a sentence, freeze it with its provenance: + +```html +The import takes 184 ms 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. diff --git a/plugins/leaf/skills/leaf/references/serving-pages.md b/plugins/leaf/skills/leaf/references/serving-pages.md index a8eb2a233..aae8398c0 100644 --- a/plugins/leaf/skills/leaf/references/serving-pages.md +++ b/plugins/leaf/skills/leaf/references/serving-pages.md @@ -91,7 +91,8 @@ leaf page state ``` 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 `, whose named wait claims the page for that session. Starting a server when the standing one is already live prints its URL diff --git a/plugins/leaf/skills/leaf/scripts/leaf/checking.py b/plugins/leaf/skills/leaf/scripts/leaf/checking.py index f9276109b..c0419493d 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/checking.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/checking.py @@ -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 @@ -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 = [ @@ -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")], ) @@ -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( diff --git a/plugins/leaf/skills/leaf/scripts/leaf/data.py b/plugins/leaf/skills/leaf/scripts/leaf/data.py index 1fcb25416..99778a21f 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/data.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/data.py @@ -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 @@ -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, diff --git a/plugins/leaf/skills/leaf/scripts/leaf/page.py b/plugins/leaf/skills/leaf/scripts/leaf/page.py index aafa0a66c..d49b3d437 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/page.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/page.py @@ -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, @@ -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. @@ -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": "", @@ -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 @@ -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"]} ) diff --git a/plugins/leaf/skills/leaf/scripts/leaf/registry.py b/plugins/leaf/skills/leaf/scripts/leaf/registry.py index b9c2c5c4c..ae4c81be6 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/registry.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/registry.py @@ -36,14 +36,19 @@ def is_aware_datetime(value) -> bool: """Leaf's self-contained date-time format: one absolute, aware instant.""" if not isinstance(value, str): return True # the declared JSON Schema owns the type complaint + return aware_instant(value) is not None + + +def aware_instant(value: str): + """The one parse of Leaf's date-time format, or None where the spelling fails.""" if not RFC3339_DATE_TIME.fullmatch(value): - return False + return None normalized = value[:-1] + "+00:00" if value[-1] in "Zz" else value try: parsed = datetime.fromisoformat(normalized) except ValueError: - return False - return parsed.utcoffset() is not None + return None + return parsed if parsed.utcoffset() is not None else None def schema_resource_registry(schema: dict): @@ -505,6 +510,31 @@ def validate_registry(registry: dict, source) -> dict: f"{path}: <{tag}> x-data input `{input_name}` source attribute " f"`{source_attr}` must be a canonical data source string" ) + if measured := entry.get("x-measured"): + input_name = measured["input"] + if input_name not in entry.get("x-data", {}): + raise RegistryError( + f"{path}: <{tag}> x-measured input `{input_name}` is not one " + "of its x-data inputs" + ) + source_attr = entry["x-data"][input_name]["source"] + if source_attr not in entry.get("required", []): + raise RegistryError( + f"{path}: <{tag}> x-measured input `{input_name}` source " + f"attribute `{source_attr}` must be required" + ) + at_attr = measured["at"] + at_schema = properties.get(at_attr) + if not ( + at_attr in entry.get("required", []) + and isinstance(at_schema, dict) + and at_schema.get("type") == "string" + and at_schema.get("format") == "date-time" + ): + raise RegistryError( + f"{path}: <{tag}> x-measured timestamp attribute `{at_attr}` " + "must be required and declare a date-time string" + ) said = set(entry.get("x-says", {})) for role in ("x-awaits", "x-conversation"): if entry.get(role) is not None and ( @@ -652,6 +682,12 @@ def validate_registry(registry: dict, source) -> dict: f"{path}: <{tag}> x-data source attributes are authored bindings, " f"but {dynamic} are written by value records" ) + if (measured := entry.get("x-measured")) and measured["at"] in mutable_values: + raise RegistryError( + f"{path}: <{tag}> x-measured timestamp attribute " + f"`{measured['at']}` is an authored snapshot instant, but is written " + "by a value record" + ) work = entry.get("x-work") if work and work["seat"] == "content": if entry.get("x-inline"): diff --git a/plugins/leaf/skills/leaf/scripts/leaf/schema.py b/plugins/leaf/skills/leaf/scripts/leaf/schema.py index 11dbb6538..79bc6d5e1 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/schema.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/schema.py @@ -225,6 +225,17 @@ def _verbs_schema( "additionalProperties": False, }, } +MEASURED_SCHEMA = { + "type": "object", + "properties": { + # The x-data input whose source timestamp says whether another run landed. + "input": {"type": "string", "pattern": f"^{HTML_NAME}$"}, + # The widget attribute holding the source snapshot's recorded instant. + "at": {"type": "string", "pattern": f"^{HTML_NAME}$"}, + }, + "required": ["input", "at"], + "additionalProperties": False, +} _ATTRIBUTE_LIST = { "type": "array", @@ -266,6 +277,7 @@ def _verbs_schema( # the element's own
, or its holder's (lf-note's `at` names a line of
         # its lf-code). `version check` refuses one outside the body (line_ref_errors).
         "x-lines": _ATTRIBUTE_LIST,
+        "x-measured": MEASURED_SCHEMA,
         # 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 as
         # a clipped word (renderQuiet), the value or, where a flag carries no value,
@@ -306,7 +318,10 @@ def _verbs_schema(
         "x-work": WORK_SCHEMA,
     },
     "required": ["x-content", "x-upgrade"],
-    "dependentRequired": {"x-retired-when": ["x-parent"]},
+    "dependentRequired": {
+        "x-retired-when": ["x-parent"],
+        "x-measured": ["x-data"],
+    },
     "additionalProperties": False,
 }
 # The keys whose value names attributes of the widget's own schema, in whichever shape
diff --git a/tests/test_interact_contract.py b/tests/test_interact_contract.py
index 1c3bce3d7..f35d7e708 100644
--- a/tests/test_interact_contract.py
+++ b/tests/test_interact_contract.py
@@ -951,6 +951,69 @@ def test_a_data_source_attribute_can_carry_ordinary_schema_metadata(page_dir):
     assert registry_model.validate_registry(registry, "test registry") is registry
 
 
+@pytest.mark.parametrize(
+    ("change", "message"),
+    [
+        (
+            lambda entry: entry["x-measured"].update({"input": "missing"}),
+            "is not one of its x-data inputs",
+        ),
+        (
+            lambda entry: entry["required"].remove("source"),
+            "source attribute `source` must be required",
+        ),
+        (
+            lambda entry: entry["required"].remove("at"),
+            "must be required and declare a date-time string",
+        ),
+        (
+            lambda entry: entry["properties"]["at"].pop("format"),
+            "must be required and declare a date-time string",
+        ),
+    ],
+)
+def test_a_measured_widget_joins_one_data_input_to_one_aware_instant(
+    page_dir, change, message
+):
+    """The generic check cannot infer a widget's source or timestamp. Its registry
+    declaration names both, and the registry door refuses a half-readable join before
+    an authored value can silently miss the freshness advisory."""
+    registry = json.loads((page_dir / "registry.json").read_text())
+    change(registry["lf-num"])
+
+    with pytest.raises(registry_model.RegistryError, match=message):
+        registry_model.validate_registry(registry, "test registry")
+
+
+def test_a_measurement_timestamp_cannot_also_be_replay_writable(page_dir):
+    """The capture instant belongs to the authored version. Letting replay write it
+    would give the browser a newer freshness boundary than file checks and page state
+    read from the immutable document."""
+    registry = json.loads((page_dir / "registry.json").read_text())
+    widget = registry["lf-num"]
+    widget["x-upgrade"] = True
+    widget["properties"]["restated"] = {"type": "boolean"}
+    widget["x-state"] = {
+        "retime": {
+            "detail": {
+                "type": "object",
+                "properties": {"at": widget["properties"]["at"]},
+                "required": ["at"],
+                "additionalProperties": False,
+            },
+            "facet": "capture",
+            "unit": "widget",
+            "record": {"kind": "value", "attr": "at", "value": "at"},
+        }
+    }
+
+    with pytest.raises(
+        registry_model.RegistryError,
+        match="x-measured timestamp attribute `at` is an authored snapshot instant",
+    ):
+        registry_model.validate_registry(registry, "test registry")
+
+
 def test_a_data_source_attribute_cannot_also_be_replay_writable(page_dir):
     """The authored document owns a widget's binding for that element lifetime. A
     value record on the same attribute would make replay paint a source that the
diff --git a/tests/test_interact_document.py b/tests/test_interact_document.py
index 12409ef74..958869bff 100644
--- a/tests/test_interact_document.py
+++ b/tests/test_interact_document.py
@@ -1781,6 +1781,71 @@ def write(version, a=""):
     assert "record behind the log" in result.output  # CliRunner folds stderr in
 
 
+def test_check_reports_a_measurement_whose_source_ran_again(page_dir):
+    """A version keeps the scalar it stated; the replaceable source contributes only
+    evidence that its measurement ran later. The same generic reading appears as
+    passing check advice and structured page state, and disappears once the authored
+    capture instant catches up."""
+
+    def write(at):
+        measured = (
+            '

The import takes 184 ms at p95.

' + ) + html = PAGE.replace("", measured + "\n") + (page_dir / "index.html").write_text(html) + files_model.version_path(page_dir, 1).write_text(html) + files_model.revision_path(page_dir, 1).write_text(html) + return html[: html.index("