diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
deleted file mode 100644
index d525375c..00000000
--- a/.github/workflows/publish-docs.yml
+++ /dev/null
@@ -1,63 +0,0 @@
- name: Publish Docs
-
- on:
- push:
- branches:
- - master
- paths:
- - 'docs/**'
- workflow_dispatch:
-
- jobs:
- publish-docs:
- name: Transform and publish documentation
- runs-on: ubuntu-latest
- env:
- ASF_ARCHIVE: https://archive.apache.org/dist/
- JENA_VERSION: 6.0.0
-
- steps:
- - name: Checkout LinkedDataHub-Apps
- uses: actions/checkout@v4
-
- - name: Set up Java 21
- uses: actions/setup-java@v4
- with:
- distribution: temurin
- java-version: '21'
-
- - name: Install Linux packages
- run: sudo apt-get update && sudo apt-get install -y xmlstarlet
-
- - name: Download Apache Jena
- run: |
- curl -sS --fail \
- "${{ env.ASF_ARCHIVE }}jena/binaries/apache-jena-${{ env.JENA_VERSION }}.tar.gz" \
- -o "${{ runner.temp }}/jena.tar.gz"
-
- - name: Unpack Jena
- run: tar -zxf jena.tar.gz
- working-directory: ${{ runner.temp }}
-
- - name: Add Jena bin to PATH
- run: echo "${{ runner.temp }}/apache-jena-${{ env.JENA_VERSION }}/bin" >> $GITHUB_PATH
-
- - name: Pull Saxon Docker image
- run: docker pull atomgraph/saxon
-
- - name: Run documentation transformation
- run: ./ttl-to-html.sh
- working-directory: docs
-
- - name: Deploy to gh-pages
- uses: peaceiris/actions-gh-pages@v4
- with:
- personal_token: ${{ secrets.GH_PAGES_TOKEN }}
- external_repository: AtomGraph/LinkedDataHub
- publish_branch: gh-pages
- publish_dir: docs/html
- destination_dir: linkeddatahub/docs
- keep_files: true
- user_name: 'github-actions[bot]'
- user_email: 'github-actions[bot]@users.noreply.github.com'
- commit_message: 'Update docs from LinkedDataHub-Apps@${{ github.sha }}'
diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl
index 1cb72a95..6879dfe7 100644
--- a/demo/northwind-traders/admin/model/ns.ttl
+++ b/demo/northwind-traders/admin/model/ns.ttl
@@ -7,6 +7,8 @@
@prefix spin: Ask the agent for something small that it has to declare rather than code, for example: Create a container for books in my dataspace, add three books with authors, and add a chart of books per author. Ask the agent for something small that it has to declare rather than code. With the Northwind Traders demo installed, for example: Create a container for new offers in my dataspace, add a 20% discount offer for three Beverages products, and add a chart of the offer prices. Then open https://localhost:4443/ in the browser. The container, the items and the chart are there, and you can keep editing them by hand —
the agent and you work on the same documents. packages list prints one tab-separated line per package — state, URI, title — so the listing greps and cuts. The registry
defaults to https://packages.linkeddatahub.com/ and --registry overrides it; it is read through the
- dataspace's Linked Data proxy rather than fetched directly, so list needs --base as much as the other two do.Try it
-
+
All three go through PATCH on the settings document, which is the live path: the change takes effect on the
- next request with no restart, but lives in the running dataspace's context dataset. Declaring the same ldh:import triple
- in config/dataspaces.trig is the permanent one, applied on restart. packages remove is idempotent.
ldh:import triple in config/dataspaces.trig
+ is the alternative that lives with the rest of the configuration. packages remove is idempotent.
To install and manage packages step by step, follow the Manage packages guide.
"""^^rdf:XMLLiteral . diff --git a/docs/reference/command-line-interface.ttl b/docs/reference/command-line-interface.ttl index 46adad34..0efd2b0b 100644 --- a/docs/reference/command-line-interface.ttl +++ b/docs/reference/command-line-interface.ttl @@ -94,17 +94,13 @@ ldh --helpOther parameters are command-specific. Most commands take the URI of the document they act on as a default (positional) parameter.
+Other parameters are command-specific. Most commands take the URI of the document they act on as a default (positional) parameter. The commands that act on the dataspace as a whole rather than on one of its documents — packages, admin clear ontology, admin create ontology, admin create group, admin create authorization, admin import ontology and admin make-public — take its base URI in that position instead.
The repeated parameters can be set once in the environment instead of being passed to every command:
| LDH_BASE | --b, --base |
+ BASE_URI, the default parameter of the commands that act on the dataspace |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| LDH_PROXY | @@ -152,14 +148,13 @@ ldh create container --parent "$LDH_BASE" --title "Categories" --slug categories|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Function | Returns | +Runs on | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
ac:absolute-path($href as xs:anyURI) |
xs:anyURI |
+ Both | Strips query string and fragment from a URI, returning the path-only form | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:build-uri( |
+ ac:build-uri( |
xs:anyURI? |
- Appends a percent-encoded query string built from the parameter map to $path |
+ Both | +Appends a percent-encoded query string built from $query-params to $absolute-path |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:document-uri($uri as item()) |
xs:anyURI |
+ Both | Strips the fragment identifier from a URI, making it suitable for use with document() |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:fragment-id($uri as xs:anyURI) |
xs:string? |
+ Both | Extracts the fragment identifier (the part after #) |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:uri() |
xs:anyURI? |
- URI of the external resource being viewed through the Linked Data proxy; absent for local documents. Works in both environments, unlike the server-side $ac:uri parameter it wraps |
+ Both | +URI of the external resource being viewed through the Linked Data proxy; absent for local documents. The server reads it from the $ac:uri parameter, the browser from the uri query parameter |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
lds:origin() |
xs:anyURI |
+ Both | Returns the origin (scheme, host and port) of the current dataspace, e.g. https://localhost:4443/. Used to build absolute URIs for static resources and same-site requests |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
lds:base() |
xs:anyURI |
+ Both | Returns the base URI of the current dataspace | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:request-uri() |
xs:anyURI |
- Returns the full request URI including query string (server-side only) | +Both | +Returns the full request URI including query string | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:base-uri($node as node()) |
+ ldh:base-uri($arg as node()) |
xs:anyURI |
+ Both | Returns the base URI of an XML node; wraps the built-in base-uri() |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:href($uri as xs:anyURI?) |
xs:anyURI |
+ Both | Resolves a URI to a local href, proxying external URIs through the LinkedDataHub proxy. Overloads accept $query-params as map(xs:string, xs:string*) and $fragment as xs:string? |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:parse-href($href as xs:anyURI) |
map(xs:string, item()?) |
+ Both | Inverse of ldh:href() — parses a local href back into its target URI, query parameters and fragment. Shared by the client-side navigation handlers |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:query-params() |
map(xs:string, xs:string*) |
+ Both | Parses the current request URI's query string into a map of parameter names to values | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:build-query($mode as xs:anyURI*) |
map(xs:string, xs:string*) |
+ Both | Builds the mode query parameter map that selects the given layout mode(s) |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:parse-query-params($query-string as xs:string) |
map(xs:string, xs:string*) |
+ Both | Parses a URL query string into a map of key to value(s) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:url-decode($encoded-string as xs:string) |
xs:string |
+ Both | Percent-decodes a URL-encoded string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Function | Returns | +Runs on | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:label($resource as element()) |
xs:string? |
+ Both | Extracts a human-readable label from an RDF resource description, trying multiple vocabulary properties | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:description($resource as element()) |
xs:string? |
+ Both | Extracts a human-readable description from an RDF resource description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:image($resource as element()) |
attribute()* |
+ Both | Extracts image references (e.g. foaf:depiction, foaf:img) from a resource description |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:property-label($property as element()) |
xs:string? |
+ Both | Label of a predicate element, capitalized — resolved by the ac:property-label mode templates. An overload accepts $property-metadata as document-node(), the property descriptions loaded for the current document |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:object-label($object as node()) |
xs:string? |
+ Both | Label of a property's object: a resource-valued object is looked up in the current graph, the loaded metadata or its dereferenced document, falling back to the URI's fragment or last path segment; a literal yields its lexical value. An overload accepts $object-metadata as document-node() |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:mode($doc as document-node()) |
xs:anyURI |
+ Both | The active layout mode of the given document. Replaces the former $ac:mode parameter |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:langs() |
xs:string* |
+ Both | The reader's accepted languages, deduplicated to primary subtags and always ending with en as a fallback — negotiated server-side, read from navigator.languages in the browser |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ac:lang-rank($value as element()) |
xs:integer |
+ Both | Ranks a property value's language against ac:langs(), so values sort by the reader's preference |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:date-time($value as xs:string?) |
xs:dateTime? |
+ Both | Parses a lexical value into a xs:dateTime where possible |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:datatype-family($datatype as xs:anyURI?) |
xs:string |
+ Both | Groups an XSD datatype into a family (numeric, dateTime, string etc.) — drives table column sorting | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:sort-key($key as xs:string?, $datatype as xs:anyURI?) |
xs:anyAtomicType? |
+ Browser | Converts a lexical value into a typed sort key for its datatype family | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:sort-key-lexical($resource as element(), $predicates as xs:anyURI*) |
xs:string? |
+ Browser | The lexical sort key of a resource: coalesces across $predicates in order, preferring values whose language matches the reader's first accepted language. Feeds ldh:sort-key() |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:sort-datatype($resources as element()*, $predicates as xs:anyURI*) |
xs:anyURI? |
+ Browser | The datatype shared by a sort column's literals; absent when the column mixes datatypes or carries plain literals or resources. Keys the typed re-sort that keeps a view block's client-side order agreeing with its SPARQL ORDER BY |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Function | Returns | +Runs on | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:query-result($endpoint as xs:anyURI, $query as xs:string) |
- document-node() |
- Executes a SPARQL query against the given endpoint and returns the result document (cached) | +document-node()? |
+ Both | +Runs $query against $endpoint over the SPARQL Protocol and returns the response: RDF/XML for CONSTRUCT and DESCRIBE, SPARQL Results XML for SELECT and ASK. Call it from any stylesheet: each engine has its own implementation. Server-side, it POSTs the query as application/sparql-query through ldh:send-request(), so a long query does not hit a URL length limit; the result is empty when the response has no body. Client-side, it sends a GET and loads the response with document(). An endpoint on another origin is fetched through the Linked Data proxy (?uri=). document() is synchronous, so the page waits for the response. SaxonJS also keeps the document for the rest of the page session, so a repeated query does not reach the network. A failed request raises a dynamic error, so wrap the call in xsl:try when the endpoint may be unavailable. Example: ldh:query-result(sd:endpoint(), 'DESCRIBE <' || @rdf:about || '>') |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:send-request( |
+ ldh:send-request( |
document-node()? |
- Makes an HTTP request and returns the response as a document (server-side extension function) | +Server | +Sends an HTTP request with any $method and parses the response body as XML; returns an empty sequence when the response has no body. A body is sent only when both $media-type and $body are given, and $body is sent as its string value, so serialize a node before passing it. $headers maps a header name to its value, of which only the first is sent; pass map{} for none. A response status outside 2xx, a timeout or an unparseable body raises a dynamic error that xsl:try can catch. Server-side only: it is the SendHTTPRequest Java extension function and has no browser declaration, so calling it from a stylesheet that also runs in the browser fails to compile. Guard such a call with use-when="system-property('xsl:product-name') = 'SAXON'"; for a SPARQL query, call ldh:query-result() instead, which works on both sides. In the browser, ixsl:http-request() is the asynchronous counterpart. Example: ldh:send-request($href, 'GET', (), (), map{ 'Accept': 'application/rdf+xml' }) |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:link-targets($link-header as xs:string?, $marker as xs:string) |
+ ldh:link-targets($link-headers as xs:string*, $marker as xs:string) |
xs:anyURI* |
- Extracts the target URIs of the Link response header entries whose parameters contain $marker (e.g. a rel value) — the parser behind acl:mode(), sd:endpoint() and ldh:timemap() (client-side) |
- ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:parse-html($string as xs:string, $mime-type as xs:string) |
- document-node() |
- Parses a markup string into a document | +Both | +Extracts the target URIs of the Link response header entries, across all Link header values, whose parameters contain $marker (e.g. a rel value) — on the server, acl:mode() and ldh:timemap() read the response's Link header through it; in the browser, it reads each response's Link header into the values that acl:mode(), sd:endpoint() and ldh:timemap() return |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:reserialize($doc as document-node()) |
document-node() |
+ Both | Round-trips a document through serialization, normalizing it | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Function | Returns | +Runs on | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
sd:endpoint() |
xs:anyURI |
+ Both | SPARQL endpoint of the current data pane — the local endpoint, or the remote dataspace's when browsing another dataspace through the proxy. Replaces the former $sd:endpoint parameter |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
lds:dataspace() |
xs:anyURI? |
+ Browser | URI of the dataspace resource describing the current dataspace. Replaces the former $lds:Dataspace parameter (its description is looked up in $lds:Context) |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
acl:mode() |
xs:anyURI* |
+ Both | Access modes granted on the current document, read from the response's Link headers. Replaces the former $acl:mode parameter |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Function | Returns | +Runs on | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:memento-datetime() |
xs:string? |
+ Both | The Memento-Datetime of the currently viewed historical version, absent on the live document |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:timemap() |
xs:anyURI? |
+ Both | The document's TimeMap URI from the Link response header; absent when the document is not versioned |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ldh:snapshot-params($query-params as map(xs:string, xs:string*)) |
- map(xs:string, xs:string*) |
- Filters a query parameter map down to the snapshot parameters (version, timemap), so they survive URL rebuilds |
-
ac:uuid()xs:stringldh:hash-code($string as xs:string)xs:integerac:uuid() for identifiers that must survive a reload because they are re-derived rather than stored (client-side)ac:value-intersect($arg1 as xs:anyAtomicType*, $arg2 as xs:anyAtomicType*)xs:anyAtomicType*ac:value-except($arg1 as xs:anyAtomicType*, $arg2 as xs:anyAtomicType*)xs:anyAtomicType*$arg1 that are not present in $arg2ac:uuid() for identifiers that must survive a reload because they are re-derived rather than storedand the same TimeMap as application/link-format:
<https://localhost:4443/orders/10248/>;rel="original", -<https://localhost:4443/orders/10248/?timemap>;rel="self";type="application/link-format", -<https://localhost:4443/orders/10248/?version=4d1e7aa>;rel="memento";datetime="Sun, 30 Aug 2026 10:12:04 GMT", -<https://localhost:4443/orders/10248/?version=8c9f2b1>;rel="memento";datetime="Tue, 01 Sep 2026 14:03:59 GMT"-
A TimeMap of a document with no versions answers 404 Not Found. A non-versioned document answers 406 Not Acceptable
+<https://localhost:4443/orders/10248/?timemap>;rel="self";type="application/link-format";from="Sun, 30 Aug 2026 10:12:04 GMT";until="Tue, 01 Sep 2026 14:03:59 GMT",
+<https://localhost:4443/orders/10248/?timegate>;rel="timegate",
+<https://localhost:4443/orders/10248/?version=4d1e7aa>;rel="first memento";datetime="Sun, 30 Aug 2026 10:12:04 GMT",
+<https://localhost:4443/orders/10248/?version=8c9f2b1>;rel="last memento";datetime="Tue, 01 Sep 2026 14:03:59 GMT"
+
A document of a versioned dataspace that no write has reached yet, such as imported data or a document that predates versioning being
+ enabled, has no versions: its TimeMap lists the Original Resource, the TimeMap itself without a from/until
+ range and the TimeGate, and no Mementos. Its next write makes the first version. A non-versioned document answers 406 Not Acceptable
when application/link-format is requested.
The Memento hypermedia uses IANA-registered relation types: the document advertises its timemap and timegate in
- Link headers, a historical version links back to the document with rel=original, and the TimeMap identifies itself with
- rel=self.
Link headers, a historical version links back to the document with rel="original", and the TimeMap identifies itself with
+ rel="self". Relation types are always quoted, as RFC 8288 requires of a URI relation type such as
+ rel="http://www.w3.org/ns/auth/acl#mode".
The ontology is cached in memory, so tell the dataspace to reload it:
ldh admin clear ontology \\
- -b "$admin_base" \\
- --ontology "${LDH_BASE}ns#"
+ --ontology "${LDH_BASE}ns#" \\
+ "$admin_base"
Copy RDF data from Linked Data resources and file uploads into your dataspace
-Forking copies the contents of any RDF document into a named graph in your dataspace. Two sources of RDF are supported: RDF resources on the web (Linked Data) and RDF file uploads.
+You can copy the contents of any RDF document into a named graph in your dataspace. Two sources of RDF are supported: RDF resources on the web (Linked Data) and RDF file uploads.
To copy the contents of the current RDF document (local or remote) into a local document, follow these steps:
For example, the Northwind Beverages category maps to the UNESCO Thesaurus concept Beverages. Open it in the - Linked Data browser and fork it into your own dataspace.
+ Linked Data browser and copy it into your own dataspace.The target document does not have to be local: you can also add data to a document in another dataspace by pasting its URI. The append is performed with your identity, so the target dataspace's access control decides whether it is allowed — a refusal surfaces as a form error.
The same fork is a pipe — ldh get the source, ldh post it into the target document:
+The same copy is a pipe — ldh get the source, ldh post it into the target document:
ldh get --accept text/turtle \\
"https://unesco-thesaurus.demo.linkeddatahub.com/concepts/concept3683/" \\
| ldh post -t text/turtle "${LDH_BASE}categories/1/"
@@ -90,5 +90,5 @@
Browse data to find resources worth forking, or import RDF data for larger datasets.
+Browse data to find resources worth copying, or import RDF data for larger datasets.
The model lives in the administration dataspace, whose origin is the dataspace origin with an admin. prefix — so -b (overriding the LDH_BASE default) and the target document URL both use $admin_base, because admin commands target the admin app's base URI, while the ontology terms being defined keep the end-user origin.
The model lives in the administration dataspace, whose origin is the dataspace origin with an admin. prefix — so the target document URL uses $admin_base, while the ontology terms being defined keep the end-user origin.
Follow the same steps for Category (schema:ProductGroup) if you want its instances to be creatable too.
+A constructor can also be changed from the form of any instance of its class. Open the resource's form with the button and click Edit constructors at the bottom of the form. A dialog lists the properties the class's constructor gives a new instance: each row is a property, switched between Resource and Literal, with the class of the resource or the datatype of the literal. Change a row, remove one with , or add one with Property, then click Save: the create forms of the class follow.
+For a resource of several classes the button becomes a menu with an entry per class. It appears once LinkedDataHub has checked that you can write the class's constructor, which in practice means membership of the owners group. A class that has no constructor yet is offered too, so its first property can be added this way.
ldh admin add property-constraint \\
- -b "$admin_base" \\
--uri "${base}ns#MissingName" \\
--label "Missing schema:name" \\
--property "https://schema.org/name" \\
@@ -158,7 +159,6 @@ ldh admin add constructor \\
ldh admin add class \\
- -b "$admin_base" \\
--uri "https://schema.org/Product" \\
--label "Product" \\
--constructor "${base}ns#ProductConstructor" \\
diff --git a/docs/user-guide/manage-dataspaces.ttl b/docs/user-guide/manage-dataspaces.ttl
index eca06d41..1663b55f 100644
--- a/docs/user-guide/manage-dataspaces.ttl
+++ b/docs/user-guide/manage-dataspaces.ttl
@@ -28,9 +28,9 @@
Create a dataspace
To create a new dataspace:
- - Add instances of
lds:EndUserDataspace and lds:AdminDataspace to config/dataspaces.trig, following the default dataspace as a template
+ - Add an
lds:Dataspace for the end-user dataspace and one for its administration dataspace to config/dataspaces.trig, each with its origin, ontology and stylesheet, following the default dataspace as a template
- Declare an end-user and an admin dataset for the dataspace in config/fuseki/config.ttl, named after its origin (deployment host dropped, role appended)
- - Add corresponding
sd:Service instances to config/system.trig, each pointing at one of those datasets, and bind the dataspaces to them
+ - In config/system.trig, type the two dataspaces
lds:EndUserDataspace and lds:AdminDataspace, add corresponding sd:Service instances, each pointing at one of those datasets, and bind the dataspaces to them
The relationship between end-user and admin dataspaces is inferred from their origins, and there is nothing to declare. A dataspace whose origin
(lds:origin) uses the admin. subdomain is recognized as an administration dataspace, and paired with the end-user dataspace on
diff --git a/docs/user-guide/version-history.ttl b/docs/user-guide/version-history.ttl
index f901deb9..b0642fd2 100644
--- a/docs/user-guide/version-history.ttl
+++ b/docs/user-guide/version-history.ttl
@@ -27,7 +27,8 @@
Open the history
Click the document's last-modified timestamp in the action bar. The
- History dialog opens with the document's versions listed newest first, each showing its datetime and the agent who authored it.
+ History dialog opens with the document's versions listed newest first, each showing its datetime and the agent who authored it. A document no write has reached since versioning was enabled, such as imported data,
+ has no versions yet: the dialog says so, and the document's next change makes the first one.
The version you are currently viewing is marked.
Click a version to view the document as it was at that point. A notice banner reminds you that you are viewing a historical version and
links back to the current one. Historical versions are read-only.
diff --git a/lib/ldh-app.sh b/lib/ldh-app.sh
index 6401090d..48819be8 100755
--- a/lib/ldh-app.sh
+++ b/lib/ldh-app.sh
@@ -52,9 +52,9 @@ ldh_app_import_ns() {
printf "Clearing ontology from memory: %sns#\n" "$LDH_BASE"
ldh admin clear ontology \
- -b "$admin_base" \
--proxy "$admin_proxy" \
- --ontology "${LDH_BASE}ns#"
+ --ontology "${LDH_BASE}ns#" \
+ "$admin_base"
}
# Runs one ldh import csv per row of the manifest $2 — query_filename,csv_filename,target,title, paths
diff --git a/packages/README.md b/packages/README.md
index ae24907b..a297a7b8 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -165,7 +165,7 @@ ldh packages remove --package https://packages.linkeddatahub.com/editor/taxonomy
`packages list` prints one tab-separated line per package — state, URI, title. The registry defaults
to `https://packages.linkeddatahub.com/`; `--registry` overrides it. It is read through the
-dataspace's Linked Data proxy rather than fetched directly, so `list` needs `--base` as much as
+dataspace's Linked Data proxy rather than fetched directly, so `list` needs the dataspace URI as much as
the other two do.
The dataspace settings modal offers the same thing as a checkbox per package, saved with the rest
diff --git a/screencast/FINDINGS.md b/screencast/FINDINGS.md
index cb71c38d..68491c70 100644
--- a/screencast/FINDINGS.md
+++ b/screencast/FINDINGS.md
@@ -339,7 +339,7 @@ category, territory and employee pages list what points at them.
## 11. A stack restart drops the dataspace's package imports
-**Where** `linkeddatahub.com` compose stack; `ldh packages list -b https://northwind-traders…`.
+**Where** `linkeddatahub.com` compose stack; `ldh packages list https://northwind-traders…`.
**What happens** After `docker compose restart` the Taxonomy Editor package reads
`available` again, and a `packages add` issued while the entrypoint is still initialising is
@@ -445,6 +445,30 @@ browser's drag image under the pointer, which the synthetic path never could.
---
+## 16. Restore is offered on a historical version, and fails there with 412
+
+**Severity** Confusing — the button is there, says "This version could not be
+restored", and the user has no way to tell why.
+
+**Reproduce**
+1. Open a versioned document, open History, pick From and To, Compare. The page now
+ shows a historical version ("You are viewing a read-only historical version").
+2. Open History again and press Restore on any version. Confirm.
+3. "This version could not be restored — The request did not complete"; the console
+ shows the `PUT … 412 (Precondition Failed)`.
+
+`scenes/probe-versions.mjs` does this on a scratch document; `scenes/probe-restore.mjs`
+shows the same Restore succeeding (`PUT 200` with `If-Match`) when pressed from the
+current version of the document.
+
+**Cause** (hypothesis) The restore quotes the entity tag of the page it was pressed
+from. A historical version's ETag is its commit SHA, which never matches the current
+document, so the conditional write is refused. Either the dialog should not offer
+Restore while a historical version is displayed, or it should quote the current
+version's tag.
+
+---
+
## Not reproduced / not attributed
- **`Terminated with [object DocumentFragment]`** appears twice on essentially every
diff --git a/screencast/docs/manifest.mjs b/screencast/docs/manifest.mjs
index 026592b9..7ef82c5e 100644
--- a/screencast/docs/manifest.mjs
+++ b/screencast/docs/manifest.mjs
@@ -669,12 +669,12 @@ export const SHOTS = [
// Actions ▸ Save as: Source is the remote document, Graph the local one it lands in.
// The source is the concept the page itself names, fetched through the Linked Data
// proxy from the public UNESCO Thesaurus dataspace; the target is this run's scratch
- // document, so nothing is forked into the demo data.
+ // document, so nothing is copied into the demo data.
doc: 'user-guide/add-data', n: 1, line: 31, kind: 'clip', writes: true,
- caption: 'forking a remote RDF document into the dataspace',
+ caption: 'copying a remote RDF document into the dataspace',
at: '/',
async act({ page, cursor, type, marks, scratch, sleep }) {
- const url = await scratch.document('fork', 'Forking a remote document');
+ const url = await scratch.document('copy', 'Copying a remote document');
await page.goto(url, { waitUntil: 'load' });
await page.waitForTimeout(5000);
await marks.beat('open');
@@ -707,12 +707,12 @@ export const SHOTS = [
await cursor.click(modal.locator('button').filter({ hasText: /Save/ }).last());
await page.waitForTimeout(9000);
// The copy lands in the graph behind the page, which does not re-render on its
- // own; the clip ends on the forked document as the reader would next see it.
+ // own; the clip ends on the copy as the reader would next see it.
await page.goto(url, { waitUntil: 'load' });
await page.waitForTimeout(6000);
- await marks.beat('forked');
+ await marks.beat('copied');
},
- // The fork succeeded when the local document describes what the remote one did.
+ // The copy succeeded when the local document describes what the remote one did.
want: async (page) => {
const text = await page.locator('.ldh-pane.is-active').innerText().catch(() => '');
return /beverage|boisson|concept|skos/i.test(text);
diff --git a/screencast/lib/blocks.mjs b/screencast/lib/blocks.mjs
index fb31455e..1574db55 100644
--- a/screencast/lib/blocks.mjs
+++ b/screencast/lib/blocks.mjs
@@ -111,7 +111,7 @@ export async function pickByLabel(page, cursor, type, input, label, { kind = nul
// Prose. The editor autosaves when focus leaves it, so the beat is: open, type,
// look away — no Save to hunt for.
-export async function addProse(page, cursor, type, text, { settle = 1800, heading = false } = {}) {
+export async function addProse(page, cursor, type, text, { settle = 1800, heading = false, onOpen = null } = {}) {
const btn = addButton(page, 'XHTML');
if (!(await btn.count())) return { ok: false, why: 'no + XHTML button — is the document in ContentMode?' };
@@ -125,6 +125,8 @@ export async function addProse(page, cursor, type, text, { settle = 1800, headin
opened = await editable.waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false);
}
if (!opened) return { ok: false, why: 'editor did not open after three presses' };
+ // A scene may mark the opened editor here, so a cut can frame it before the typing.
+ if (onOpen) await onOpen(editable);
await cursor.click(editable);
await sleep(300);
diff --git a/screencast/lib/editing.mjs b/screencast/lib/editing.mjs
index 327359c6..091be7a9 100644
--- a/screencast/lib/editing.mjs
+++ b/screencast/lib/editing.mjs
@@ -18,7 +18,11 @@ export async function editResource(page, cursor, hasText, { settle = 3000 } = {}
if (!(await block.count())) return { ok: false, why: `no block matching ${hasText}` };
const btn = block.locator('button').filter({ hasText: /^\s*edit\s*$/ }).first();
if (!(await btn.count())) return { ok: false, why: 'the block has no edit control' };
- await btn.scrollIntoViewIfNeeded();
+ // Centred, not merely in view: scrolled to the viewport's edge the control sits under
+ // the sticky action bar, and the pointer's click opens the Actions menu instead
+ // (measured on a product page, 2026-10-03).
+ await btn.evaluate((el) => el.scrollIntoView({ block: 'center' }));
+ await sleep(500);
await cursor.click(btn);
const form = formOf(page);
const shown = await form.waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false);
diff --git a/screencast/lib/fixture.mjs b/screencast/lib/fixture.mjs
index bed2934e..aa46dc95 100644
--- a/screencast/lib/fixture.mjs
+++ b/screencast/lib/fixture.mjs
@@ -23,12 +23,12 @@ function run(cmd, args, input = null) {
});
}
-export async function resetDocument({ ldh, base, certFile, certPassword, certPasswordFile, container, slug, title }) {
+export async function resetDocument({ ldh, certFile, certPassword, certPasswordFile, container, slug, title }) {
const password = certPassword ?? (certPasswordFile ? (await fs.readFile(certPasswordFile, 'utf8')).trim() : null);
if (!password) throw new Error('resetDocument needs --cert-password or --cert-password-file');
// The CLI takes its options after the subcommand, not before it.
- const auth = ['-b', base.endsWith('/') ? base : `${base}/`, '-c', certFile, '-p', password];
+ const auth = ['-c', certFile, '-p', password];
const url = `${container.replace(/\/$/, '')}/${slug}/`;
const scrub = (t) => String(t).split(password).join('••••');
@@ -57,10 +57,10 @@ export async function resetContainer({ ldh, base, certFile, certPassword, certPa
if (!password) throw new Error('resetContainer needs --cert-password or --cert-password-file');
const root = base.endsWith('/') ? base : `${base}/`;
- const auth = ['-b', root, '-c', certFile, '-p', password];
+ const auth = ['-c', certFile, '-p', password];
const scrub = (t) => String(t).split(password).join('••••');
- await run(ldh, ['delete', `${(parent ?? root).replace(/\/$/, '')}/${slug}/`, ...auth.slice(2)]);
+ await run(ldh, ['delete', `${(parent ?? root).replace(/\/$/, '')}/${slug}/`, ...auth]);
const made = await run(ldh, ['create', 'container', ...auth, '--parent', parent ?? root, '--title', title, '--slug', slug]);
if (made.code !== 0) throw new Error(`could not create container ${slug}: ${scrub(made.err || made.out).slice(0, 400)}`);
return made.out || `${root}${slug}/`;
diff --git a/screencast/lib/harness.mjs b/screencast/lib/harness.mjs
index eaca6aaa..76d214a7 100644
--- a/screencast/lib/harness.mjs
+++ b/screencast/lib/harness.mjs
@@ -62,7 +62,7 @@ export function args() {
};
// --origin is accepted as an alias so the flag reads naturally in a scene, but
- // --base is the name the ldh CLI uses and the one the env var follows.
+ // --base is the canonical name, the one the LDH_BASE env var follows.
const base = value('base', value('origin', process.env.LDH_BASE ?? DEFAULT_BASE)).replace(/\/$/, '');
return {
@@ -131,13 +131,17 @@ export async function runScene({
const context = await browser.newContext({
ignoreHTTPSErrors: true,
+ // English chrome in embedded players and date pickers, whatever the machine's locale.
+ locale: 'en-US',
viewport: { width: geometry.width, height: geometry.height },
// SCHEME=dark records the page in the dark colour scheme (the page follows
// prefers-color-scheme; nothing pins it).
...(process.env.SCHEME ? { colorScheme: process.env.SCHEME } : {}),
deviceScaleFactor: geometry.deviceScaleFactor ?? 2,
...(opts.video
- ? { recordVideo: { dir: TRACKS, size: { width: geometry.width, height: geometry.height } } }
+ // A take can record at a size other than its viewport — a phone-width page at 2×
+ // keeps a 390 px layout and its media queries while the frames are 780 wide.
+ ? { recordVideo: { dir: TRACKS, size: geometry.record ?? { width: geometry.width, height: geometry.height } } }
: {}),
...(identity ? { clientCertificates: identity } : {}),
// A scene copies a resource's URI from the app rather than knowing it, so the
@@ -237,15 +241,19 @@ export async function runScene({
const video = page.video();
await context.close(); // video is only written here — there is no way to stop it early
+ // A failed run is kept beside the take, never over it: tracks/ is not in git, so a take
+ // replaced by a few seconds of a run that failed is gone for good.
+ const savedName = failure ? `${trackName}.failed` : trackName;
+
// saveAs needs the browser alive, so it has to happen between the two closes.
let trackPath = null;
if (video) {
- trackPath = path.join(TRACKS, `${trackName}.webm`);
+ trackPath = path.join(TRACKS, `${savedName}.webm`);
await video.saveAs(trackPath);
await video.delete();
}
await browser.close();
- const marksPath = await marks.save(TRACKS, trackName);
+ const marksPath = await marks.save(TRACKS, savedName);
console.log(`\n track ${trackPath}`);
console.log(` marks ${marksPath}`);
diff --git a/screencast/lib/terminal.mjs b/screencast/lib/terminal.mjs
index ea497e81..532795af 100644
--- a/screencast/lib/terminal.mjs
+++ b/screencast/lib/terminal.mjs
@@ -126,3 +126,38 @@ function run(cmd, args, { env, quiet = false } = {}) {
child.on('close', resolve);
});
}
+
+/**
+ * The environment for a tape that runs curl against the stack, with the stack trusted.
+ *
+ * A tape's curl carries no -k: the command on camera is the one a viewer would type
+ * against a public instance. On a stack whose certificate curl cannot verify — the dev
+ * stack's names only localhost, so docs.localhost fails on the name even with the
+ * certificate as CA — the recording shell trusts it through a .curlrc of its own
+ * instead, the way the browser takes ignore HTTPS errors. Only verification is excused:
+ * the request is made here first, exactly as the tape makes it, and a stack that
+ * answers with anything but 200 is a failed take before anything is filmed, since
+ * `curl -s` would film an empty answer.
+ *
+ * @param url the document the tape asks for
+ * @param env the tape's variables
+ * @returns env, plus CURL_HOME when the stack had to be trusted
+ */
+export async function trustedCurlEnv(url, env = {}) {
+ const probe = (extra) => new Promise((resolve) => {
+ const p = spawn('curl', ['-s', '-o', '/dev/null', '-m', '20', '-H', 'Accept: text/turtle', '-w', '%{http_code}', url], { env: { ...process.env, ...env, ...extra } });
+ let out = '';
+ p.stdout.on('data', (d) => (out += d));
+ p.on('close', (code) => resolve({ code, status: out.trim() }));
+ });
+ let r = await probe({});
+ let trusted = {};
+ if (r.code === 60 || r.code === 35) {
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'ldh-curl-'));
+ await fs.writeFile(path.join(home, '.curlrc'), 'insecure\n');
+ trusted = { CURL_HOME: home };
+ r = await probe(trusted);
+ }
+ if (r.code !== 0 || r.status !== '200') throw new Error(`${url} answers ${r.status || `curl exit ${r.code}`} to a GET for Turtle; the tape would film nothing`);
+ return { ...env, ...trusted };
+}
diff --git a/screencast/overview/SCRIPT.md b/screencast/overview/SCRIPT.md
index a107a4c9..1e4a252a 100644
--- a/screencast/overview/SCRIPT.md
+++ b/screencast/overview/SCRIPT.md
@@ -1,80 +1,180 @@
-# LinkedDataHub overview — narration script (draft)
+# LinkedDataHub overview — narration script (draft 2)
The flagship film: every feature `docs/about.ttl` lists, in three chapters, narrated with an
ElevenLabs voice. It fills the About page's "Screen recording: LinkedDataHub overview" slot
and replaces "What's new in LinkedDataHub v3?" on YouTube.
-The voice sets the timing. Each line is synthesised on its own, and its shot holds for the
-line's length plus a short pause — the picture is cut to the voice, never the other way round.
-Target ≈ 600 words, 4–5 minutes.
+**Voice**: Daniel (`onwK4e9ZLuTAKqWW03F9`), model `eleven_multilingual_v2`. Measured on the
+draft-1 intro line: 40 words in 17.8 s, about 135 words a minute. So a line of 25 words
+holds its shot for 11 s; every line below is kept to 30 words or fewer.
-Captions become an `.srt` generated from these lines; only chapter titles are burned in.
+**Flows, not shots.** Draft 1 had 26 one-gesture shots at 11 s each, which showed features
+without leaving time to take them in. This draft groups the features into continuous
+sequences on one screen, so the narration runs across a flow instead of re-establishing the
+scene at every feature. Target 6–7 minutes, roughly 600 words.
+
+The voice sets the timing: each line is synthesised on its own, and its shot holds for the
+line's length plus a short pause. Captions become an `.srt` generated from these lines; only
+chapter titles are burned in.
## Intro
| # | On screen | Narration |
|---|---|---|
-| 0.1 | Graph mode on a document, nodes settling | LinkedDataHub is an open-source platform for Knowledge Graphs. Everything you see here — the documents, the forms, the charts, even the rules for who may read them — is RDF data, served through one HTTP API. |
+| 0.1 | Graph mode on an order, maximised, the camera orbiting while the layout settles | LinkedDataHub is an open-source Knowledge Graph application platform. It serves federated Linked Data dataspaces from a SPARQL-compliant triplestore, and works with any RDF Knowledge Graph out of the box. |
+| 0.2 | The orbit continues, closing in | It works as a thin proxy layer on top of the triplestore, providing the user interface, authentication, authorization, versioning, and the other enterprise-grade features this video shows. |
## Chapter 1 — Knowledge management
+### Flow A — Browsing (tree → container → modes → graph → related results → search)
+
+| # | On screen | Narration |
+|---|---|---|
+| A1 | The drawer opens on the document tree; a container is opened | The left sidebar shows the document tree and the classes in the data. Every document is also a named graph in the backend triplestore. Here we open the Territories container. |
+| A2 | The container's child listing cycled: list, table, grid, map | Containers are like folders: they hold items and other containers. Here, the Territories container's children are shown as a list, a table, a grid and a map. |
+| A3 | Graph mode; a node double-clicked, the graph grows; a node opened | Graph mode renders resources as an interactive 3D network. Double-clicking the order's customer loads the customer's own document into the graph, and the node opens as a page. |
+| A4 | On the opened document, a facet filters the orders to the delivered ones; then the pivot bar re-centres the results on a related class | A view renders a paginated SPARQL query projection. Facets narrow these orders to delivered ones, and parallax navigation jumps to the sales reps who took them. |
+| A5 | The search dialog; a label typed; typed results | The search box finds resources by their titles and labels. Typing Seattle finds the Seattle territory. |
+| A6 | A film's page on a review site typed into the address bar; it opens in a new tab and renders as a resource through the proxy; an actor's link followed in the same tab | The address bar also opens external URLs, one tab per origin. RDF documents can be browsed, as can web pages with embedded JSON-LD, like this film and its actors. |
+
+### Flow B — Authoring (query → results → chart → page → annotation → embed → drag)
+
+| # | On screen | Narration |
+|---|---|---|
+| B1 | A SELECT typed into a new query document and saved; its results render | Queries, views and charts are resources like any other, stored in documents and placed on pages as blocks. This query counts the sales reps per region, with its results below. |
+| B2 | A chart bound to the query from its action bar; the bars draw | A chart is bound to a query by choosing the chart type, the category and the series. Here, a bar chart of the reps per region. |
+| B3a | The document back in Properties mode: the chart above the query it was made from; the pointer to the mode menu | Properties mode shows all of the document's resources, in no particular order: here, the chart and the query we just made. |
+| B3 | Content mode on the document; a sentence written in a prose block | Content mode renders a list of blocks: XHTML text, and objects transcluded from Linked Data resources, such as views, charts or assistant conversations. Text is edited in place, like this sentence about the Eastern region. |
+| B4 | A word selected; the annotation dialog; the word linked to a resource | Select a word and annotate it with RDFa to link it to a resource. Here, the word chart is linked to the chart we just made. |
+| B5 | + Object; the chart's URI as the value; the chart embedded in the page | An object block embeds any resource by its URI: a query, a chart, a view, or Linked Data from elsewhere. Here it embeds the chart. |
+| B5d | The chart's block dragged by its handle above the sentence | Dragging a block by its handle moves it to a new place: the chart now comes before the sentence. |
+| B5b | + Object; a YouTube link typed as the value; Save; the video card | A video link embeds the same way: its metadata is read as RDF, and Tim Berners-Lee's TED talk plays on the page. |
+
+### Flow C — Bringing data in (fork → CSV import → drop a file → drop RDF)
+
| # | On screen | Narration |
|---|---|---|
-| 1.1 | The drawer opens on the document tree; a container is opened | A dataspace is a tree of documents. Each document is a named graph, with a URL you can open, bookmark or link to. |
-| 1.2 | One view cycled through list, table, grid, map, chart | A container's contents are the results of a SPARQL query, and the same results can be laid out as a list, a table, a grid, a map or a chart. |
-| 1.3 | Graph mode: a node double-clicked, the graph grows, a node opened | In graph mode, documents are nodes and links are edges. Opening a node follows the link into another document. |
-| 1.4 | The pivot bar: results re-centre on a related class | Related results follow a property from one set of results to the next. |
-| 1.5 | The search dialog: a label typed, typed results | Search finds resources by their labels, across the whole dataspace. |
-| 1.6 | A SELECT typed and saved; its results render; a chart is bound to it | A SPARQL query is saved as a document like any other. Its results render on the page, and a chart can be bound to it. |
-| 1.7 | Content mode: a prose block written, the chart embedded, a block dragged | Content mode turns a document into a page. Prose sits beside embedded resources — a chart, a map, a view — and dragging a block reorders the page. |
-| 1.8 | A word selected, the annotation dialog, the word linked | Prose is edited in place. Select a word, link it to a resource, and the sentence carries a machine-readable statement. |
-| 1.9 | A remote RDF document opened through the proxy, then forked | Linked Data from anywhere on the Web opens through the proxy, and can be forked into the dataspace in one step. |
-| 1.10 | The CSV import form: file, mapping query, the imported container | A CSV file is imported by mapping each row to RDF with a SPARQL query. |
-| 1.11 | A photo dropped on a document; then a Turtle file dropped | Dropping a file on a document uploads it. Dropping an RDF file imports it. |
-| 1.12 | The History dialog, a version diff, a restore | With versioning enabled, every write becomes a version. The history shows who changed what, the diff shows how, and any version can be restored. |
+| C1 | A remote RDF document opened through the proxy, then copied into the dataspace with Save as | Linked Data documents can be copied into your own dataspace. Here, the Beverages concept from the UNESCO Thesaurus is copied into a Northwind document. |
+| C2 | The CSV import form: file, mapping query; the imported container | A CSV import maps each row to RDF with a SPARQL CONSTRUCT query. Here, the products file, and the query that maps a row to a product. |
+| C3 | A photo dropped on a document; then a Turtle file dropped on another | Drop a file on a document to upload it, like this employee photo. Drop an RDF file, and its triples are added to the document's graph. |
## Chapter 2 — Low-code platform
+### Flow E — The model builds the interface (create → violation → save → view → edit → constructor)
+
+| # | On screen | Narration |
+|---|---|---|
+| E1 | Create in a property view's header; the generated form | Views can also be attached to a property in the ontology, and render on every resource of its domain or range. Here, Create opens a form built by the Product constructor. |
+| E2 | Every other required field filled (identifier, description, category, provider), the name left empty; Save; the violation on the field | Every write is validated against the model's constraints. Leave the product's name empty, and the form shows the violation on that field. |
+| E3 | The field filled; Save; the same view picks up the record | With the name filled in, Save writes the product. Views are backed by SPARQL queries, so Beverages now lists thirteen products instead of twelve. |
+| E3b | The new record opened; the pencil; the description rewritten; Save; the page re-reads | To edit a resource, open its form with the pencil button. Here, the new product's description is rewritten. |
+| E3c | The record's form again; Edit constructors; the Product constructor dialog, its properties and their types; Cancel | Edit constructors opens the Product constructor: the properties every new product starts with, each a typed literal or a resource of some class. |
+
+### Flow D — Versions (history → diff → restore)
+
+| # | On screen | Narration |
+|---|---|---|
+| D1 | The History dialog on a document that has been edited twice | With versioning enabled, every write to a document is a commit in a GitHub repository, with its datetime and the agent who made it. Here, the Rockville territory. |
+| D2 | A version diff; then a restore; the page re-reads | Select two versions to see the diff: the description changed. Restore writes an old version back as a new one, and the versions in between stay. |
+
+### The interface (colour scheme → phone width)
+
+| # | On screen | Narration |
+|---|---|---|
+| E4 | The page switches dark to light and back | The interface follows the reader's light or dark colour scheme, and switches with it, without a reload. |
+| E4p | The same page at phone width, scrolled | The layout is responsive: on a phone the same page reflows to a single column. A dataspace can also customize any part of its UI by overriding the default templates in a custom stylesheet. |
+
+### Flow F — The CLI and access (push → live → make-public → private window)
+
+| # | On screen | Narration |
+|---|---|---|
+| F1 | Terminal: `ldh push` a folder of Turtle; the plan prints, the URLs stream | The ldh command line drives the same HTTP API as the user interface. Here, `ldh push` uploads LinkedDataHub's own documentation, which is itself a dataspace. Recursively, each Turtle file becomes a document, and every other file an upload. |
+| F2 | The browser: the pushed dataspace, rendered | And the documentation is live. Scripts and agents use the same HTTP API as the browser does. |
+| F3 | Terminal: `ldh admin create group`, `ldh admin create authorization`, `ldh admin make-public`; then the page in a private window, signed out | Access control uses W3C ACL authorizations, which are RDF too. Here the CLI creates an Editors group, lets it write the user guide, and makes the dataspace public. |
+| F3b | Terminal: `ldh get --accept text/turtle` on the home page just made public; its Turtle on screen | Every document has both an HTML and an RDF representation. Here, the home page is fetched as Turtle. |
+| F4a | Signed out: Sign up, the WebID sign-up form | Signing up creates a WebID profile and issues a client certificate. In this video, the certificate is already installed in the browser, which presents it to log you in. |
+| F4 | The Login menu: Login with Google, Login with ORCID | You can also log in with Google or ORCID. |
+
+### Flow H — Agents (ask → plan → execute → the chat stays → an agent over MCP)
+
+The assistant (H1–H4) ships in 6.1.0: an `ldh:Chat` block started from the create bar, a question
+written as a Web-Algebra plan by the stack's `web-algebra` service, run only on Execute, under the
+reader's own access control, each turn stored as an `ldh:ChatTurn`. Shooting it needs the
+`web-algebra` service and the `openai_api_key` secret on the stack.
+
+The MCP half (H5–H7) runs Web-Algebra 2.0.0, built from the local checkout: it sends `If-Match` on
+POST, PUT and PATCH (PyPI 1.5.0 does not, and every write to an existing document was refused
+`428 Precondition Required` on 2026-10-03), and its `ldh-CreateContainer` and `ldh-CreateItem` write
+the current document-hierarchy namespace (2026-10-05; with the old LDT one a new container was taken
+for an item and refused children). The command on camera is the docs page's `uv run --with
+web-algebra`, resolved to that build through `UV_FIND_LINKS` off camera.
+
| # | On screen | Narration |
|---|---|---|
-| 2.1 | Create ▸ a class; the generated form filled and saved | Forms are generated from the model. A class's constructor decides which fields a new instance starts with, and Save writes the graph. |
-| 2.2 | An empty required field submitted; the violation on the field | SHACL and SPIN constraints are checked on every write, and a violation is shown on the field that caused it. |
-| 2.3 | A class's view on an instance page, re-reading after the save | Views declared on a class appear on every one of its instances, and pick up changes by themselves. |
-| 2.4 | The sign-in screen: WebID, Google, ORCID | Agents sign in with a WebID certificate, or with Google or ORCID. |
-| 2.5 | Terminal: `ldh admin make-public`; the page in a private window | Access is controlled by authorizations, which are documents too. One command makes a dataspace public. |
-| 2.6 | The page switches dark to light, then narrows to phone width | The interface follows the reader's colour scheme and screen size, and a dataspace can override any part of it with its own stylesheet. |
-| 2.7 | The dataspace menu; a second dataspace opened in a tab | One instance hosts many dataspaces, each with its own data, model and rules. |
-| 2.8 | Terminal: `ldh push` a folder; the new dataspace in the browser | The command line uses the same HTTP API as the interface. Push a folder of Turtle files, and it becomes a dataspace — documents, model and all. |
+| H1 | A Northwind document; the create bar's Assistant button; "Chart the five customers who ordered the most" typed; the steps report as the plan runs | The assistant takes a question in plain words and writes a Web-Algebra plan. A plan that only reads runs right away, like this one about Northwind's best customers. |
+| H2 | The answer's sentence; the rows drawn as a bar chart; the trace unfolded, its three steps | The assistant answers in a sentence and draws the results in any of the layout modes. Here, the five customers with the most orders. |
+| H3 | A follow-up: "Add the chart to this page"; the plan card waits, AddSelect, AddResultSetChart and AddObjectBlock listed; Execute; the page catches up with the new chart block | A plan that writes waits until you press Execute. This one adds the chart to the page as a block. |
+| H4 | The page reloaded; the chat block with both turns, the composer under them | The conversation is a block in the document, stored as RDF, so it is still there after a reload. |
+| H5 | Terminal: `claude mcp add web-algebra …`, then `claude`, `/mcp` listing it as connected | Web-Algebra is a separate open-source project: composable operations for loading, querying and writing Linked Data. Its MCP server gives agents like Claude Code these operations as tools. |
+| H6 | Terminal: the prompt from the docs page, aimed at the Northwind dataspace; Claude Code's tool calls going by, then its summary | Ask for something to build rather than code. Here: new offers, a twenty percent discount on three Beverages products, and a chart of their prices. |
+| H7 | The browser: the offers container, the three offers, the chart | The container, the offers and the chart are there, and you can keep editing them by hand. |
## Chapter 3 — Packages
+### Flow G — One triple (before → add → after → edit)
+
| # | On screen | Narration |
|---|---|---|
-| 3.1 | A SKOS concept rendered as a plain resource | A package adds support for a vocabulary to a dataspace. |
-| 3.2 | Terminal: `ldh packages list`, `ldh packages add …/editor/taxonomy/#this` | Installing one takes a single triple, written here from the command line. |
-| 3.3 | The same concept: the tree in the content column, narrower and broader views | The taxonomy editor package brings a concept tree, views for broader and narrower concepts, and forms that ask for a label in every language. |
-| 3.4 | The package ontology under `admin/ontologies/`, opened for editing | Its ontology is copied into the dataspace, where it is edited like your own. |
+| G1 | A SKOS concept rendered as a plain resource, no tree | A package adds support for an RDF vocabulary. Without one, this SKOS concept, Denmark, renders as a plain resource. |
+| G2 | Terminal: `ldh packages list`, then `ldh packages add …/editor/taxonomy/#this` | Installing a package takes a single triple, and `ldh packages add` writes it. |
+| G3 | The same concept: the tree in the content column, broader and narrower views | With the taxonomy editor package installed, Denmark gets a concept tree, and views of its broader and narrower concepts. |
+| G4 | The package ontology under `admin/ontologies/`, opened for editing | The package's ontology is copied into the administration dataspace, where you can edit it like your own. |
## Close
| # | On screen | Narration |
|---|---|---|
-| 4.1 | The footage fades into the title card | Classes, forms, views, charts and access rules are all RDF documents. The application is data. LinkedDataHub is open source, under the Apache 2.0 licence. |
+| Z1 | The footage fades into the title card | LinkedDataHub is open source, under the Apache 2.0 licence. |
+
+Forty lines: about 6 minutes of speech, 9½–10 minutes with the pauses and the gestures that
+run under no narration (the cut of 2026-10-05 is 9.7 minutes).
+
+## Takes
+
+One scene per flow under `scenes/overview-*.mjs`, each firing `-start` and `-end`
+beats named after the shots above (plus intermediate ones), so the cut places a line at its
+shot's start beat and holds the shot to its end beat. Dark, 2× (2880×1800 with the document
+zoomed 200 %), the supercut's recipe; the graph takes at 1×, where the canvas is sharp.
+
+| Take | Shots | Notes |
+|---|---|---|
+| `overview-0-graph` | 0.1 | an order in Graph mode, maximised to the whole frame (no page chrome, the canvas's controls and the pointer hidden) and orbited by the camera, closing in, while the layout settles; 1×. The take forces the maximised canvas to the full height: a later `.content-body > .graph-3d-canvas` rule in `ldh.css` sets it back to `height: auto`, 117 px short |
+| `overview-a-browse` | A1, A2, A5 | the tree, the modes, search |
+| `overview-a-graph` | A3, A4 | the order graph expanded and opened; Related results; 1× |
+| `overview-b-author` | B1–B5 | scratch document `overview-compose`; the chart's URI is copied from its block after a reload (the just-saved render's copy control writes an empty string). Cut from take 7 (2026-10-15, `overview-b-author-take7`), shot on LinkedDataHub with the fix for a new XHTML block lost after its save in Chrome: B1 brings Save to rest above the sticky create bar; B5b follows B5d without a cut, scrolls only what is off screen, and halves the page's `min-height: 100vh`, which the rig's CSS zoom doubles, from B5b on |
+| `overview-c-data` | C1–C3 | three scratch documents; the CSV form is not saved. C1, C1b and C2 are cut from take 4 (2026-10-14, `overview-c-data-take4`): after Save the copy renders in place, so the scene no longer waits a fixed 9 s and reloads (which held the copy's document block ~13 s and showed the breadcrumb reloading); it scrolls to the copied Concept block and holds it, and the cut plays that part (C1b, from `C1-saved`) at 1× while C1 runs at 1.3×. C2 leaves the delimiter the constructor fills (the first take typed a second comma over it, `,,`); C3 is cut from take 5 (`overview-c-data-take5`): the dropped Turtle is a whole territory (identifier, region, latitude, longitude), as the model now requires of a `schema:City` |
+| `overview-d-versions` | D1, D2 | a fresh scratch slug per run, three PUTs through ldh; Compare from the first described version; Restore from the current version (FINDINGS #16). Cut from take 2 (2026-10-14, `overview-d-versions-take2`): the fixture writes a whole territory (identifier, region, latitude, longitude), as the model now requires of a `schema:City`, and Rockville carries schema:name alone (the first take, lost on 2026-10-09 and kept only as cut clips in `tracks/overview-d{1,2}-kept.mp4`, also showed a Title row) |
+| `overview-e-model` | E1–E4 | Product on Beverages' products view; cut from take 3 (2026-10-14, `overview-e-model-take3`, after LinkedDataHub's fix for a saved row losing its objects' labels), shot after the Northwind model gained constraints aligned with the imports: every other required field filled (identifier, description "Red tea", category Beverages, provider Exotic Liquids), the name empty, refused by `:MissingName` alone; E3b rewrites the description; then the scheme switch. E2 plays at 2.2×. Recut from take 7 (2026-10-15, `overview-e-model-take7`): E3c opens Edit constructors from the form E3b opened and saves it after, instead of reopening the form; the page's `min-height` is halved just before that save |
+| `overview-e-phone` | E4 (phone) | 390×844 at device scale 2, recorded at 780×1688, light; composited beside the desktop frame |
+| `overview-f-cli` | F1–F3b | docs.localhost emptied, its public authorization reseeded, and the previous take's Editors group and authorization removed off camera; three terminal takes recorded inside the browser take (`tapes/overview-f-push.tape`; `tapes/overview-f-public.tape`: create group, create authorization, make-public, the first two taking the administration base URI as their positional; `tapes/overview-f-get.tape`: `ldh get --accept text/turtle`) |
+| `overview-f-anon` | F3 (anonymous) | run with `--anonymous` after the CLI take. Not in the cut since 2026-10-14: the 16:9 crop removed the top bar, its only difference from F2, and the take's bar shows Sign up alone (no Google or ORCID yet); F4 shows the signed-out page instead |
+| `overview-f-login` | F4 | recorded clean 2026-10-14 on docs.localhost with `--anonymous`, once the dev stack had the Google and ORCID clients (the menu lists only configured providers). The menu first (`F4-menu-*`), then Sign up on the administration host (`F4-webid-*`); cut in the narration's order, the form before the menu. The sign-up form's empty text inputs draw no border in the dark scheme, so only Based near reads as a field |
+| `overview-h-assistant` | H1–H4 | recorded clean 2026-10-05 (94 s, 15 beats). The scratch document `overview-assistant` ("Our best customers") reset off camera; a live model (`gpt-5.5`), so the plan and the answer's wording differ per take — the beats assert the card's states, not its text. The question runs on arrival (~20 s to the steps, cut); "Add the chart to this page" plans as Sequence(AddSelect, AddResultSetChart, AddObjectBlock), which needed REST-VKG's `ldh-operations.md` to say a view or chart is placed by `AddObjectBlock`; "as a view" fails, since a follow-up builds on the rows as seen (names, not customer URIs). Bar charts start at zero and a chart block's Save goes with its controls since LinkedDataHub's fix of the same day. The plan card's summary prints the document URL, `localhost` in frame until the public re-shoot Cut from take 2 (2026-10-15, `overview-h-assistant-take2`): the scene holds the chat's own scrollIntoView while filming and eases each question to the top, so it stays in view while its card grows; the model's waits are kept and played fast (H1w at 6×, H3w at 7×) instead of cut. Shot on an image without LinkedDataHub ca3e3f427, whose post-write reload left the page blank |
+| `overview-h-agent` | H5–H7 | recorded clean 2026-10-05: the terminal take (`tapes/overview-h-agent.tape`, 149 s; the agent's run 55 s, cut at 4.5×) from the linkeddatahub.com deployment's directory, then the books container in the browser (the docs' old books prompt; re-shot 2026-10-15 with the Northwind offers prompt). Off camera: the previous take's `offers/` documents removed, the Web-Algebra wheel built, `CLAUDE*` variables cleared (an inherited one puts a warning in Claude Code's footer), claude.ai connectors and the Figma plugin hidden from `/mcp`, the MCP server removed after. The agent's titles and authors differ per take. `localhost` in frame until the public re-shoot |
+| `overview-g-packages` | G1–G4 | the package removed off camera; the terminal take (`tapes/overview-g-packages.tape`) recorded between G1 and G3 |
## Pronunciation
-ElevenLabs reads acronyms unpredictably; these go into a pronunciation dictionary (or are
-respelled in the text sent to the API, never in this script):
+ElevenLabs reads acronyms unpredictably; these are respelled in the text sent to the API,
+never in this script or the subtitles. A command in the narration is written in backticks
+(`` `ldh push` ``): it is said as one phrase, with a short pause on either side, and the
+subtitles show it without them.
| Written | Spoken |
|---|---|
| LinkedDataHub | Linked Data Hub |
| SPARQL | sparkle |
-| RDF, RDFa | R D F, R D F A |
| SKOS | skos (rhymes with "boss") |
-| SHACL | shackle |
-| SPIN | spin |
-| WebID | web I D |
| ORCID | orkid |
| `ldh` | L D H |
| CSV | C S V |
@@ -83,12 +183,17 @@ respelled in the text sent to the API, never in this script):
- **Synthesis**: one request per line to the ElevenLabs text-to-speech API, cached under a
hash of voice, model and text, so an unchanged line is never re-billed and a render is
- reproducible. The API key lives outside the repository.
-- **Licence**: commercial use of the output needs a paid ElevenLabs plan; the free tier
- requires attribution and excludes commercial use.
+ reproducible. The API key is read from `~/.config/elevenlabs/api-key`; the plan is
+ Starter (30,000 credits a month; a full pass of the script is about 3,500).
+- **Music**: generated with ElevenLabs Music to the film's length — an understated
+ electronic bed, no vocals, nothing that competes with speech, with a lift for the title
+ card — so the licence is the plan's. (The supercut's track was made in Suno.)
+- **Sound**: no effects on the interface, which makes no sounds; one soft tone, generated
+ once, marks each chapter card.
- **Disclosure**: the YouTube description says the narration is a synthetic voice.
- **Mix**: voice placed at each shot's start, music ducked under it (`sidechaincompress`),
`highpass` + `loudnorm` to −14 LUFS — all in the local ffmpeg.
-- **Prerequisites before shooting**: the demo dataspaces reinstalled on v6 at the public
- origin, versioning enabled on Northwind (1.12), and the package registry answering (3.2 —
- `https://packages.linkeddatahub.com/` returned 500 on 2026-09-29).
+- **Stacks**: built and piloted on the linkeddatahub.com dev stack on localhost; the final
+ pass on the public stack, so URIs in frame read the public addresses. Both need the three
+ demos on v6, versioning on Northwind (D1–D2), the registry reachable (G2) and an empty
+ dataspace to push into (F1).
diff --git a/screencast/overview/cut.json b/screencast/overview/cut.json
new file mode 100644
index 00000000..d30a7520
--- /dev/null
+++ b/screencast/overview/cut.json
@@ -0,0 +1,659 @@
+{
+ "output": "tracks/overview.mp4",
+ "width": 1920,
+ "height": 1080,
+ "fps": 25,
+ "crf": 20,
+ "source": {
+ "width": 2880,
+ "height": 1800
+ },
+ "viewport": {
+ "width": 2880,
+ "height": 1620
+ },
+ "terminal": {
+ "width": 1440,
+ "height": 810
+ },
+ "maxZoom": 1.7,
+ "minZoom": 1.15,
+ "leadSeconds": 0.3,
+ "trailSeconds": 0.1,
+ "lineOffset": 0.5,
+ "linePad": 1.0,
+ "card": {
+ "seconds": 3.4,
+ "fade": 0.5
+ },
+ "voice": "tracks/overview/voice.json",
+ "music": {
+ "file": "tracks/overview/music.mp3",
+ "gain": -22,
+ "duck": -14,
+ "fadeOut": 2.0
+ },
+ "slide": {
+ "eyebrow": "Open-source Knowledge Graph platform",
+ "title": "The application is data.",
+ "lede": "Classes, forms, views, charts and access rules are RDF resources. Push a folder of Turtle and the dataspace is live. An agent can write it.",
+ "links": [
+ {
+ "label": "linkeddatahub.com",
+ "primary": true
+ },
+ {
+ "label": "github.com/AtomGraph/LinkedDataHub"
+ }
+ ],
+ "note": "Open source under Apache 2.0",
+ "fade": 1.2,
+ "seconds": 6,
+ "at": 1.0
+ },
+ "shots": [
+ {
+ "id": "0.1",
+ "line": "0.1",
+ "track": "overview-0-graph",
+ "from": "0.1-start",
+ "to": "0.1-end",
+ "toOffset": 2.5,
+ "move": "static",
+ "focus": false,
+ "scale": 1
+ },
+ {
+ "id": "0.2",
+ "line": "0.2",
+ "track": "overview-0-graph",
+ "from": "0.1-end",
+ "fromOffset": 2.5,
+ "to": "0.2-end",
+ "move": "static",
+ "focus": false,
+ "scale": 1
+ },
+ {
+ "card": "Knowledge management",
+ "eyebrow": "Chapter 1"
+ },
+ {
+ "id": "A1",
+ "line": "A1",
+ "track": "overview-a-browse",
+ "from": "A1-start",
+ "to": "A1-end",
+ "move": "zoom",
+ "via": [
+ "A1-tree",
+ "A1-expanded"
+ ],
+ "minZoom": 1.0,
+ "speed": 0.86
+ },
+ {
+ "id": "A2",
+ "line": "A2",
+ "track": "overview-a-browse",
+ "from": "A2-start",
+ "to": "A2-end",
+ "move": "static",
+ "minZoom": 1.0
+ },
+ {
+ "id": "A3",
+ "line": "A3",
+ "track": "overview-a-graph",
+ "from": "A3-start",
+ "to": "A3-end",
+ "move": "static",
+ "focus": false,
+ "scale": 1,
+ "speed": 1.4
+ },
+ {
+ "id": "A4",
+ "line": "A4",
+ "track": "overview-a-graph",
+ "from": "A4-start",
+ "to": "A4-end",
+ "move": "zoom",
+ "scale": 1,
+ "maxZoom": 2.0,
+ "minZoom": 1.4,
+ "via": [
+ "A4-open",
+ "A4-values",
+ "A4-filtered",
+ "A4-pill"
+ ],
+ "speed": 1.02
+ },
+ {
+ "id": "A5",
+ "line": "A5",
+ "track": "overview-a-browse",
+ "from": "A5-start",
+ "to": "A5-end",
+ "move": "static",
+ "focus": false
+ },
+ {
+ "id": "A6",
+ "line": "A6",
+ "track": "overview-a-browse",
+ "from": "A6-start",
+ "to": "A6-end",
+ "move": "zoom",
+ "via": [
+ "A6-start",
+ "A6-typed",
+ "A6-rendered",
+ "A6-link"
+ ],
+ "minZoom": 1.0,
+ "speed": 0.94
+ },
+ {
+ "id": "B1",
+ "line": "B1",
+ "track": "overview-b-author-take7",
+ "from": "B1-start",
+ "to": "B1-end",
+ "move": "zoom",
+ "via": [
+ "B1-typed",
+ "B1-scrolled",
+ "B1-save"
+ ],
+ "speed": 1.2
+ },
+ {
+ "id": "B2",
+ "line": "B2",
+ "track": "overview-b-author-take7",
+ "from": "B2-start",
+ "to": "B2-end",
+ "move": "zoom",
+ "via": [
+ "B2-drawn"
+ ],
+ "speed": 1.3
+ },
+ {
+ "id": "B3a",
+ "line": "B3a",
+ "track": "overview-b-author-take7",
+ "from": "B3-start",
+ "fromOffset": -6,
+ "to": "B3-content",
+ "move": "static",
+ "focus": false
+ },
+ {
+ "id": "B3",
+ "line": "B3",
+ "track": "overview-b-author-take7",
+ "from": "B3-content",
+ "to": "B3-end",
+ "move": "zoom",
+ "via": [
+ "B3-content",
+ "B3-editor"
+ ],
+ "toOffset": 1.5,
+ "speed": 0.67
+ },
+ {
+ "id": "B4",
+ "line": "B4",
+ "track": "overview-b-author-take7",
+ "from": "B4-start",
+ "to": "B4-end",
+ "move": "zoom",
+ "speed": 1.6
+ },
+ {
+ "id": "B5",
+ "line": "B5",
+ "track": "overview-b-author-take7",
+ "from": "B5-start",
+ "to": "B5-fitted",
+ "toOffset": 1.3,
+ "move": "zoom",
+ "via": [
+ "B5-embedded"
+ ],
+ "speed": 1.3,
+ "minZoom": 1.0
+ },
+ {
+ "id": "B5d",
+ "line": "B5d",
+ "track": "overview-b-author-take7",
+ "from": "B5-fitted",
+ "fromOffset": 1.3,
+ "to": "B5-end",
+ "move": "zoom",
+ "via": [
+ "B5-dropped"
+ ],
+ "minZoom": 1.0,
+ "toOffset": 0.9,
+ "speed": 0.58
+ },
+ {
+ "id": "B5b",
+ "line": "B5b",
+ "track": "overview-b-author-take7",
+ "from": "B5-end",
+ "to": "B5b-end",
+ "move": "static",
+ "speed": 1.3,
+ "fromOffset": 0.9,
+ "focus": false
+ },
+ {
+ "id": "C1",
+ "line": "C1",
+ "track": "overview-c-data-take4",
+ "from": "C1-start",
+ "to": "C1-saved",
+ "move": "zoom",
+ "via": [
+ "C1-form",
+ "C1-source"
+ ],
+ "speed": 1.3
+ },
+ {
+ "id": "C1b",
+ "track": "overview-c-data-take4",
+ "from": "C1-saved",
+ "to": "C1-end",
+ "move": "zoom"
+ },
+ {
+ "id": "C2",
+ "line": "C2",
+ "track": "overview-c-data-take4",
+ "from": "C2-start",
+ "to": "C2-end",
+ "move": "zoom",
+ "via": [
+ "C2-form",
+ "C2-file",
+ "C2-query"
+ ],
+ "speed": 1.6
+ },
+ {
+ "id": "C3",
+ "line": "C3",
+ "track": "overview-c-data-take5",
+ "from": "C3-start",
+ "to": "C3-end",
+ "move": "static",
+ "focus": false,
+ "speed": 1.3
+ },
+ {
+ "card": "Low-code platform",
+ "eyebrow": "Chapter 2"
+ },
+ {
+ "id": "E1",
+ "line": "E1",
+ "track": "overview-e-model-take7",
+ "from": "E1-start",
+ "to": "E1-end",
+ "move": "zoom",
+ "fromOffset": -6.0
+ },
+ {
+ "id": "E2",
+ "line": "E2",
+ "track": "overview-e-model-take7",
+ "from": "E2-start",
+ "to": "E2-end",
+ "move": "zoom",
+ "via": [
+ "E2-save"
+ ],
+ "speed": 2.2
+ },
+ {
+ "id": "E3",
+ "line": "E3",
+ "track": "overview-e-model-take7",
+ "from": "E3-start",
+ "to": "E3-end",
+ "move": "zoom",
+ "via": [
+ "E3-filled"
+ ],
+ "speed": 0.82
+ },
+ {
+ "id": "E3b",
+ "line": "E3b",
+ "track": "overview-e-model-take7",
+ "from": "E3b-form",
+ "to": "E3c-start",
+ "move": "zoom",
+ "via": [
+ "E3b-filled"
+ ],
+ "minZoom": 1.0,
+ "speed": 1.0,
+ "fromOffset": -3.5
+ },
+ {
+ "id": "E3c",
+ "line": "E3c",
+ "track": "overview-e-model-take7",
+ "from": "E3c-start",
+ "to": "E3c-end",
+ "move": "zoom",
+ "via": [
+ "E3c-dialog",
+ "E3c-cancelled"
+ ],
+ "minZoom": 1.0
+ },
+ {
+ "id": "D1",
+ "line": "D1",
+ "track": "overview-d-versions-take2",
+ "from": "D1-start",
+ "to": "D1-end",
+ "move": "zoom",
+ "minZoom": 1.0,
+ "speed": 0.39
+ },
+ {
+ "id": "D2",
+ "line": "D2",
+ "track": "overview-d-versions-take2",
+ "from": "D2-start",
+ "to": "D2-end",
+ "move": "zoom",
+ "via": [
+ "D2-picked",
+ "D2-diff",
+ "D2-current",
+ "D2-restore"
+ ],
+ "speed": 1.5,
+ "minZoom": 1.0
+ },
+ {
+ "id": "E4",
+ "line": "E4",
+ "track": "overview-e-model-take7",
+ "from": "E4-start",
+ "to": "E4-end",
+ "move": "static",
+ "focus": false
+ },
+ {
+ "id": "E4p",
+ "track": "overview-e-phone",
+ "from": "E4-phone-start",
+ "to": "E4-phone-end",
+ "kind": "phone",
+ "line": "E4p"
+ },
+ {
+ "id": "F1",
+ "line": "F1",
+ "track": "overview-f-push-terminal",
+ "from": 0,
+ "to": 22,
+ "kind": "terminal"
+ },
+ {
+ "id": "F2",
+ "line": "F2",
+ "track": "overview-f-cli",
+ "from": "F2-start",
+ "to": "F2-end",
+ "move": "static",
+ "focus": false
+ },
+ {
+ "id": "F3",
+ "line": "F3",
+ "track": "overview-f-public-terminal",
+ "from": 0,
+ "to": 32.5,
+ "kind": "terminal",
+ "speed": 2
+ },
+ {
+ "id": "F3b",
+ "line": "F3b",
+ "track": "overview-f-get-terminal",
+ "from": 0,
+ "to": 9.5,
+ "kind": "terminal"
+ },
+ {
+ "id": "F4a",
+ "line": "F4a",
+ "track": "overview-f-login",
+ "from": "F4-webid-start",
+ "to": "F4-webid-end",
+ "move": "static",
+ "focus": false,
+ "speed": 0.39
+ },
+ {
+ "id": "F4",
+ "line": "F4",
+ "track": "overview-f-login",
+ "from": "F4-menu-start",
+ "to": "F4-menu-end",
+ "move": "static",
+ "focus": {
+ "x": 1500,
+ "y": 0,
+ "w": 1380,
+ "h": 780
+ }
+ },
+ {
+ "id": "H1a",
+ "track": "overview-h-assistant-take2",
+ "from": 8.6,
+ "to": "H1-asked",
+ "move": "zoom",
+ "minZoom": 1.0,
+ "speed": 1.6
+ },
+ {
+ "id": "H1w",
+ "track": "overview-h-assistant-take2",
+ "from": "H1-asked",
+ "to": "H1-running",
+ "move": "zoom",
+ "minZoom": 1.0,
+ "speed": 6
+ },
+ {
+ "id": "H1",
+ "line": "H1",
+ "track": "overview-h-assistant-take2",
+ "from": "H1-running",
+ "to": "H1-end",
+ "move": "zoom",
+ "minZoom": 1.0,
+ "speed": 0.71
+ },
+ {
+ "id": "H2",
+ "line": "H2",
+ "track": "overview-h-assistant-take2",
+ "from": "H2-start",
+ "to": "H2-end",
+ "move": "zoom",
+ "via": [
+ "H2-chart"
+ ],
+ "minZoom": 1.0
+ },
+ {
+ "id": "H3a",
+ "track": "overview-h-assistant-take2",
+ "from": "H3-start",
+ "to": "H3-asked",
+ "move": "zoom",
+ "minZoom": 1.0,
+ "speed": 1.3
+ },
+ {
+ "id": "H3w",
+ "track": "overview-h-assistant-take2",
+ "from": "H3-asked",
+ "to": "H3-plan",
+ "move": "zoom",
+ "minZoom": 1.0,
+ "speed": 7
+ },
+ {
+ "id": "H3",
+ "line": "H3",
+ "track": "overview-h-assistant-take2",
+ "from": "H3-plan",
+ "to": "H3-end",
+ "move": "zoom",
+ "via": [
+ "H3-execute",
+ "H3-written"
+ ],
+ "minZoom": 1.0
+ },
+ {
+ "id": "H4",
+ "line": "H4",
+ "track": "overview-h-assistant-take2",
+ "from": "H4-start",
+ "to": "H4-end",
+ "move": "zoom",
+ "via": [
+ "H4-chat"
+ ],
+ "minZoom": 1.0
+ },
+ {
+ "id": "H5",
+ "line": "H5",
+ "track": "overview-h-agent-terminal",
+ "from": 1,
+ "to": 31,
+ "kind": "terminal",
+ "speed": 1.7
+ },
+ {
+ "id": "H6",
+ "line": "H6",
+ "track": "overview-h-agent-terminal",
+ "from": 31,
+ "to": 45,
+ "kind": "terminal",
+ "speed": 1.14
+ },
+ {
+ "id": "H6b",
+ "track": "overview-h-agent-terminal",
+ "from": 45,
+ "to": 128.5,
+ "kind": "terminal",
+ "speed": 6
+ },
+ {
+ "id": "H6c",
+ "track": "overview-h-agent-terminal",
+ "from": 128.5,
+ "to": 133.5,
+ "kind": "terminal"
+ },
+ {
+ "id": "H7",
+ "line": "H7",
+ "track": "overview-h-agent",
+ "from": "H7-start",
+ "to": "H7-end",
+ "move": "zoom",
+ "via": [
+ "H7-chart"
+ ],
+ "minZoom": 1.0
+ },
+ {
+ "card": "Packages",
+ "eyebrow": "Chapter 3"
+ },
+ {
+ "id": "G1",
+ "line": "G1",
+ "track": "overview-g-packages",
+ "from": "G1-start",
+ "to": "G1-end",
+ "move": "static",
+ "focus": false,
+ "speed": 0.42
+ },
+ {
+ "id": "G2",
+ "line": "G2",
+ "track": "overview-g-packages-terminal",
+ "from": 1,
+ "to": 24,
+ "kind": "terminal",
+ "speed": 1.3
+ },
+ {
+ "id": "G3",
+ "line": "G3",
+ "track": "overview-g-packages",
+ "from": 40.4,
+ "to": "G3-end",
+ "move": "zoom",
+ "via": [
+ "G3-tree"
+ ],
+ "minZoom": 1.0
+ },
+ {
+ "id": "G4",
+ "line": "G4",
+ "track": "overview-g-packages",
+ "from": "G4-start",
+ "to": "G4-end",
+ "move": "zoom",
+ "via": [
+ "G4-doc"
+ ],
+ "minZoom": 1.0
+ },
+ {
+ "id": "Z1",
+ "line": "Z1",
+ "track": "overview-0-graph",
+ "from": "0.1-start",
+ "to": "0.1-start",
+ "hold": 11.2,
+ "move": "static",
+ "focus": false,
+ "scale": 1,
+ "kind": "close",
+ "lineOffset": 2.6
+ }
+ ],
+ "tone": {
+ "file": "tracks/overview/tone.mp3",
+ "gain": -8
+ }
+}
diff --git a/screencast/overview/voice.mjs b/screencast/overview/voice.mjs
new file mode 100644
index 00000000..c77fe85a
--- /dev/null
+++ b/screencast/overview/voice.mjs
@@ -0,0 +1,142 @@
+// The overview's narration, synthesised one line at a time.
+//
+// SCRIPT.md is the source: every table row whose first cell is a shot id (0.1, A1 … Z1)
+// carries one line of narration, and the Pronunciation table says how the acronyms are
+// respelled for the voice — in the text sent to the API only, never in the script or the
+// subtitles. Each line goes to ElevenLabs on its own, so its length is known before any
+// shot is recorded: the voice sets the timing, and the picture is cut to it.
+//
+// Every take is cached under a hash of voice, model and the spoken text, in
+// tracks/overview/voice/, so an unchanged line is never billed twice and a render is
+// reproducible. The with-timestamps endpoint is used, so each take also keeps its
+// character alignment for the subtitles.
+//
+// node overview/voice.mjs [--api-key-file ~/.config/elevenlabs/api-key] [--dry-run]
+//
+// Writes tracks/overview/voice.json — id, text, spoken, file, seconds per line — and
+// prints the lengths.
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import { createHash } from 'node:crypto';
+import { TRACKS } from '../lib/harness.mjs';
+
+const HERE = path.dirname(new URL(import.meta.url).pathname);
+const SCRIPT = path.join(HERE, 'SCRIPT.md');
+const OUT = path.join(TRACKS, 'overview');
+const CACHE = path.join(OUT, 'voice');
+
+const VOICE = 'onwK4e9ZLuTAKqWW03F9'; // Daniel
+const MODEL = 'eleven_multilingual_v2';
+const FORMAT = 'mp3_44100_128';
+
+const args = process.argv.slice(2);
+const flag = (name, fallback) => {
+ const i = args.indexOf(name);
+ return i === -1 ? fallback : args[i + 1];
+};
+const dryRun = args.includes('--dry-run');
+const keyFile = flag('--api-key-file', path.join(os.homedir(), '.config/elevenlabs/api-key'));
+
+// ── the script ──────────────────────────────────────────────────────────────────
+
+const md = await fs.readFile(SCRIPT, 'utf8');
+
+const cells = (row) => row.split('|').slice(1, -1).map((c) => c.trim());
+
+const lines = [];
+const pronunciation = [];
+let section = null;
+for (const raw of md.split('\n')) {
+ if (raw.startsWith('## ')) section = raw.slice(3).trim();
+ if (!raw.startsWith('|')) continue;
+ const c = cells(raw);
+ if (section === 'Pronunciation') {
+ if (c.length === 2 && c[0] !== 'Written' && !/^-+$/.test(c[0])) {
+ pronunciation.push({ written: c[0].replace(/`/g, ''), spoken: c[1].replace(/\s*\(.*\)$/, '') });
+ }
+ continue;
+ }
+ if (c.length === 3 && /^(0\.\d|[A-Z]\d[a-z]?)$/.test(c[0])) lines.push({ id: c[0], screen: c[1], text: c[2] });
+}
+if (lines.length === 0) throw new Error(`no narration rows found in ${SCRIPT}`);
+
+// One pass over the text, matching the longest written form first, so "RDFa" is taken
+// whole by its own row and never has "RDF" respelled inside it by the shorter one.
+pronunciation.sort((a, b) => b.written.length - a.written.length);
+const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const written = new RegExp(pronunciation.map((p) => escape(p.written)).join('|'), 'g');
+const spokenFor = new Map(pronunciation.map((p) => [p.written, p.spoken]));
+const respell = (text) => text.replace(written, (m) => spokenFor.get(m));
+
+// A command in backticks (`ldh packages add`) is said as one phrase, set apart from the
+// sentence around it by a short pause on either side; the subtitles show it without the
+// backticks. The break tags are part of the spoken text, and the alignment covers them
+// character for character, so the cues still line up with it.
+const COMMAND = /`([^`]+)`/g;
+const BREAK = ' ';
+const speak = (text) => respell(text.replace(COMMAND, (_, cmd) => `${BREAK}${cmd}${BREAK}`));
+const plain = (text) => text.replace(COMMAND, '$1');
+
+// ── the voice ───────────────────────────────────────────────────────────────────
+
+const takeFor = (spoken) => createHash('sha1').update(`${VOICE}\n${MODEL}\n${spoken}`).digest('hex');
+
+async function synthesise(apiKey, spoken) {
+ const url = `https://api.elevenlabs.io/v1/text-to-speech/${VOICE}/with-timestamps?output_format=${FORMAT}`;
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: spoken, model_id: MODEL }),
+ });
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`);
+ const body = await res.json();
+ return { audio: Buffer.from(body.audio_base64, 'base64'), alignment: body.alignment };
+}
+
+// The length comes from the alignment — the end of the last character — rather than from
+// ffprobe, so a dry run over a warm cache needs no ffmpeg.
+const secondsOf = (alignment) => {
+ const ends = alignment?.character_end_times_seconds ?? [];
+ return ends.length ? ends[ends.length - 1] : null;
+};
+
+await fs.mkdir(CACHE, { recursive: true });
+let apiKey = null;
+let billed = 0;
+const out = [];
+
+for (const line of lines) {
+ const spoken = speak(line.text);
+ const take = takeFor(spoken);
+ const mp3 = path.join(CACHE, `${take}.mp3`);
+ const json = path.join(CACHE, `${take}.json`);
+ let alignment;
+ try {
+ alignment = JSON.parse(await fs.readFile(json, 'utf8'));
+ } catch {
+ if (dryRun) {
+ out.push({ ...line, text: plain(line.text), spoken, file: null, seconds: null });
+ continue;
+ }
+ apiKey ??= (await fs.readFile(keyFile, 'utf8')).trim();
+ const { audio, alignment: a } = await synthesise(apiKey, spoken);
+ await fs.writeFile(mp3, audio);
+ await fs.writeFile(json, JSON.stringify(a));
+ alignment = a;
+ billed += spoken.length;
+ }
+ out.push({ ...line, text: plain(line.text), spoken, file: path.relative(TRACKS, mp3), seconds: secondsOf(alignment) });
+}
+
+await fs.writeFile(path.join(OUT, 'voice.json'), JSON.stringify(out, null, 2));
+
+const words = (t) => t.split(/\s+/).length;
+let total = 0;
+for (const l of out) {
+ total += l.seconds ?? 0;
+ const s = l.seconds == null ? ' (not synthesised)' : `${l.seconds.toFixed(1).padStart(5)} s`;
+ console.log(`${l.id.padEnd(4)} ${s} ${String(words(l.text)).padStart(2)} words ${l.text.slice(0, 70)}${l.text.length > 70 ? '…' : ''}`);
+}
+console.log(`\n${out.length} lines, ${(total / 60).toFixed(1)} min of speech, ${billed} characters billed this run`);
diff --git a/screencast/render/overview.mjs b/screencast/render/overview.mjs
new file mode 100644
index 00000000..619f6b22
--- /dev/null
+++ b/screencast/render/overview.mjs
@@ -0,0 +1,371 @@
+// Cutting the overview film from its takes, to the voice.
+//
+// The cut list (overview/cut.json) is the script's shots in order, each naming a take
+// and the beats it runs between, plus chapter cards between the chapters and the
+// closing card at the end. The narration decides the timing: a shot holds for at least
+// its line (the cached ElevenLabs take in tracks/overview/voice.json) plus a pause, on
+// the last frame if the gesture was shorter, and a gesture longer than its line keeps
+// its own length, or is played a little faster where the list says so.
+//
+// Framing is the supercut's: a take is 2880×1800, and a shot is a 16:9 window on it
+// that zooms gently onto the beat's focus box (or sits still). Terminal takes are
+// 1440×810 and fill the frame; the phone take sits in the frame at its own aspect.
+//
+// Sound: every line placed at its shot's start, music under the lot and ducked while a
+// line plays, loudness to −14 LUFS, a fade at the very end. Subtitles come out as an
+// .srt beside the film, cued from the lines' character alignments.
+//
+// node render/overview.mjs [overview/cut.json] [--only A1,A2] [--no-audio]
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { chromium } from 'playwright';
+import { ROOT } from '../lib/harness.mjs';
+
+const argv = process.argv.slice(2);
+const cfgPath = path.resolve(argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--only' && argv[i - 1] !== '--reuse') ?? path.join(ROOT, 'overview', 'cut.json'));
+const cfg = JSON.parse(await fs.readFile(cfgPath, 'utf8'));
+const onlyArg = argv.indexOf('--only');
+const only = onlyArg === -1 ? null : new Set(argv[onlyArg + 1].split(','));
+const noAudio = argv.includes('--no-audio');
+// --reuse keeps the clips already cut (tracks/.overview/.mp4) and re-cuts only the ids
+// named after it, so a change to one shot does not re-encode the film's thirty.
+const reuseArg = argv.indexOf('--reuse');
+const reuse = reuseArg === -1 ? null : new Set((argv[reuseArg + 1] ?? '').startsWith('--') || !argv[reuseArg + 1] ? [] : argv[reuseArg + 1].split(','));
+const at = (p) => path.resolve(ROOT, p);
+const TRACKS = at('tracks');
+const TMP = path.join(TRACKS, '.overview');
+await fs.mkdir(TMP, { recursive: true });
+
+function run(cmd, args) {
+ return new Promise((resolve, reject) => {
+ const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
+ let out = '', err = '';
+ p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d));
+ p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`${cmd} ${args.slice(0, 8).join(' ')}…\n${err.slice(-1200)}`))));
+ });
+}
+async function duration(file) {
+ return Number((await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file])).trim());
+}
+
+// ── beats ───────────────────────────────────────────────────────────────────────
+const marksCache = new Map();
+async function beat(track, ref) {
+ if (typeof ref === 'number') return { at: ref };
+ if (!marksCache.has(track)) marksCache.set(track, JSON.parse(await fs.readFile(path.join(TRACKS, `${track}.marks.json`), 'utf8')));
+ const b = marksCache.get(track).beats.findLast((x) => x.beat === ref);
+ if (!b) throw new Error(`${track}: no beat ${ref}`);
+ return b;
+}
+
+// ── the voice ───────────────────────────────────────────────────────────────────
+const voice = new Map();
+for (const l of JSON.parse(await fs.readFile(at(cfg.voice), 'utf8'))) voice.set(l.id, { ...l, file: path.join(TRACKS, l.file) });
+async function alignmentOf(line) {
+ return JSON.parse(await fs.readFile(line.file.replace(/\.mp3$/, '.json'), 'utf8'));
+}
+
+// ── framing (the supercut's) ─────────────────────────────────────────────────────
+function frameFor(box, W, H, { maxZoom = 2.4, minZoom = 1.4, pad = 0.1 } = {}, bounds = { W, H }) {
+ if (!box) return { w: W, h: H, cx: W / 2, cy: H / 2 };
+ let w = box.w * (1 + 2 * pad), h = box.h * (1 + 2 * pad);
+ if (w / h > W / H) h = w * H / W; else w = h * W / H;
+ w = Math.max(w, W / maxZoom); h = Math.max(h, H / maxZoom);
+ w = Math.min(w, W); h = Math.min(h, H);
+ let cy = box.y + box.h / 2;
+ if (W / w < minZoom) { w = W / minZoom; h = H / minZoom; }
+ if (box.h > h) cy = box.y + h * 0.45;
+ let cx = box.x + box.w / 2;
+ cx = Math.min(Math.max(cx, w / 2), bounds.W - w / 2); cy = Math.min(Math.max(cy, h / 2), bounds.H - h / 2);
+ return { w, h, cx, cy };
+}
+// One eased value over the keys — the camera's zoom, centre and crop are all built the
+// same way, so they move together. `tv` is the filter's own time variable: zoompan counts
+// frames, crop reads seconds.
+function pathExpr(keys, pick, M, tv) {
+ const f = (v) => v.toFixed(3);
+ const lerp = (a, b, k) => `(${f(a)}+(${f(b)}-${f(a)})*${k})`;
+ let expr = f(pick(keys[keys.length - 1]));
+ for (let i = keys.length - 2; i >= 0; i--) {
+ const t1 = keys[i + 1].t, m = Math.max(0.01, Math.min(M, t1 - keys[i].t));
+ const u = `(max(0,min(1,(${tv}-${f(t1 - m)})/${f(m)})))`;
+ const k = `(3*pow(${u},2)-2*pow(${u},3))`;
+ expr = `if(lt(${tv},${f(t1)}),${lerp(pick(keys[i]), pick(keys[i + 1]), k)},${expr})`;
+ }
+ return expr;
+}
+function zoomPath(keys, M, W, fps, out) {
+ const dim = (name) => pathExpr(keys, (K) => (name === 'z' ? W / K.F.w : K.F[name]), M, `(in/${fps})`);
+ return `fps=${fps},zoompan=z='${dim('z')}':x='${dim('cx')}-iw/(2*zoom)':y='${dim('cy')}-ih/(2*zoom)':d=1:s=${out.width}x${out.height}:fps=${fps}`;
+}
+
+// ── cards, in the platform's type ────────────────────────────────────────────────
+let browser = null;
+const fontFile = at('../../LinkedDataHub/src/main/webapp/static/com/atomgraph/linkeddatahub/css/fonts/geist.woff2');
+const monoFile = at('../../LinkedDataHub/src/main/webapp/static/com/atomgraph/linkeddatahub/css/fonts/geist-mono.woff2');
+const esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/ `${(n * k).toFixed(2)}px`;
+ const pills = (links ?? []).map((l) => `${esc(l.label)}`).join('');
+ return `
+ LinkedDataHub
+ ${eyebrow ? `${esc(eyebrow)}
` : ''}
+ ${esc(title)}
+ ${lede ? `${esc(lede)}
` : ''}
+ ${pills ? `${pills}` : ''}
+ ${note ? `${esc(note)}
` : ''}
+ `;
+}
+async function renderCard(spec, out) {
+ browser ??= await chromium.launch();
+ const page = await browser.newPage({ viewport: { width: cfg.width, height: cfg.height }, deviceScaleFactor: 1 });
+ await page.setContent(await cardHtml(spec, cfg.width, cfg.height));
+ await page.evaluate(() => document.fonts.ready);
+ await page.screenshot({ path: out });
+ await page.close();
+}
+
+// ── one clip per entry ───────────────────────────────────────────────────────────
+const { width, height, fps, crf } = cfg;
+const enc = ['-an', '-c:v', 'libx264', '-preset', 'medium', '-crf', String(crf), '-pix_fmt', 'yuv420p', '-g', String(fps), '-force_key_frames', 'expr:eq(n,0)', '-video_track_timescale', '90000'];
+const fit = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=#0f1419,fps=${fps}`;
+
+async function cutCard(entry, i) {
+ const id = `card-${i}`;
+ const kept = path.join(TMP, `${id}.mp4`);
+ if (reuse && await fs.access(kept).then(() => true, () => false)) { const len = await duration(kept); return { id, kind: 'card', file: kept, len, line: null }; }
+ const png = path.join(TMP, `${id}.png`);
+ await renderCard({ eyebrow: entry.eyebrow, title: entry.card }, png);
+ const seconds = entry.seconds ?? cfg.card.seconds, fade = cfg.card.fade;
+ const out = path.join(TMP, `${id}.mp4`);
+ await run('ffmpeg', ['-y', '-loglevel', 'error', '-loop', '1', '-t', String(seconds), '-i', png, '-vf', `fade=t=in:st=0:d=${fade},fade=t=out:st=${(seconds - fade).toFixed(2)}:d=${fade},fps=${fps},format=yuv420p`, ...enc, out]);
+ const len = await duration(out);
+ console.log(` ${id.padEnd(4)} "${entry.card}" ${len.toFixed(2)}s`);
+ return { id, kind: 'card', file: out, len, line: null };
+}
+
+async function cutShot(shot, prev) {
+ const kept = path.join(TMP, `${shot.id}.mp4`);
+ if (reuse && !reuse.has(shot.id) && await fs.access(kept).then(() => true, () => false)) {
+ const len = await duration(kept);
+ console.log(` ${shot.id.padEnd(4)} (kept) ${len.toFixed(2)}s`);
+ return { ...shot, file: kept, len, lineTake: shot.line ? voice.get(shot.line) : null };
+ }
+ let src = path.join(TRACKS, `${shot.track}.webm`);
+ if (!(await fs.access(src).then(() => true, () => false))) src = path.join(TRACKS, `${shot.track}.mp4`);
+ const bFrom = await beat(shot.track, shot.from), bTo = await beat(shot.track, shot.to);
+ const same = shot.from === shot.to;
+ // A shot that picks the take up where the one before it put it down (same track, starting
+ // on the beat that one ended on) starts on that one's last frame: it has no gesture of its
+ // own to lead into, and a lead would replay the frames the previous shot already showed.
+ const continues = prev && prev.track === shot.track && prev.to === shot.from;
+ // `fromOffset` / `toOffset` shift a shot's ends in seconds, for a beat marked after the
+ // gesture it names (a form marked once open; the shot starts a moment before).
+ const from = Math.max(0, bFrom.at - (same ? 0 : continues ? cfg.trailSeconds : cfg.leadSeconds) + (shot.fromOffset ?? 0));
+ const to = same ? from : bTo.at - cfg.trailSeconds + (shot.toOffset ?? 0);
+ const speed = shot.speed ?? 1;
+ const gesture = (to - from) / speed;
+ const line = shot.line ? voice.get(shot.line) : null;
+ if (shot.line && !line) throw new Error(`${shot.id}: no voice line ${shot.line}`);
+ // The shot holds for its line plus the pause; the last frame is cloned for the rest. A shot's
+ // own `lineOffset` starts its line later than the default, as the closing card's does.
+ const want = Math.max(gesture, line ? line.seconds + (shot.lineOffset ?? cfg.lineOffset) + cfg.linePad : 0, shot.hold ?? 0);
+ const hold = Math.max(0, want - gesture);
+ const total = gesture + hold;
+ const filters = [];
+ if (speed !== 1) filters.push(`setpts=PTS/${speed}`);
+ if (hold > 0 && !same) filters.push(`tpad=stop_mode=clone:stop_duration=${hold.toFixed(3)}`);
+ const kind = shot.kind ?? 'page';
+ if (kind === 'page' || kind === 'close') {
+ // zoompan cuts its window in the INPUT's aspect, so a 16:10 take has to be cropped to
+ // the film's 16:9 first (180 px off its height), or every window comes out 16:10 and
+ // is squeezed into the frame. The crop is centred — 90 px off the top and the bottom
+ // — except where a key's box lies in the strip it would remove: the Create dock and
+ // a form's Save sit at the foot of the take, the breadcrumb at its head. Such a key
+ // slides the crop to keep its box, and the crop moves between keys on the same
+ // easing as the camera, so the two read as one move. Boxes are marked in take
+ // pixels; each key is framed in its own crop's coordinates.
+ const srcSize = cfg.source;
+ const W = cfg.viewport.width, H = cfg.viewport.height;
+ const centred = Math.round((srcSize.height - H) / 2);
+ const cropFor = (b) => {
+ if (!b || b.h > H) return centred;
+ if (b.y < centred) return Math.max(0, Math.round(b.y - 24));
+ if (b.y + b.h > centred + H) return Math.min(srcSize.height - H, Math.round(b.y + b.h + 24 - H));
+ return centred;
+ };
+ const bounds = { W, H };
+ const limits = { maxZoom: shot.maxZoom ?? cfg.maxZoom, minZoom: shot.minZoom ?? cfg.minZoom, pad: shot.pad ?? 0.12 };
+ const key = (t, b, lim = limits) => {
+ const c = cropFor(b);
+ return { t, c, F: b ? frameFor({ ...b, y: b.y - c }, W, H, lim, bounds) : { w: W, h: H, cx: W / 2, cy: H / 2 } };
+ };
+ const box = shot.focus === false ? null : (shot.focus ?? bTo.focus ?? bFrom.focus ?? null);
+ const B = key(Math.max(0.01, gesture), box);
+ // a continuing shot opens on the framing the previous one closed on, so the camera carries
+ // through the join instead of snapping back to the whole frame
+ let start = key(0, null);
+ if (continues) {
+ const prevBox = prev.focus === false ? null : (prev.focus ?? bFrom.focus ?? (await beat(prev.track, prev.from)).focus ?? null);
+ start = key(0, prevBox, { maxZoom: prev.maxZoom ?? cfg.maxZoom, minZoom: prev.minZoom ?? cfg.minZoom, pad: prev.pad ?? 0.12 });
+ }
+ const keys = [shot.move === 'static' ? { ...B, t: 0 } : start];
+ if (Array.isArray(shot.via)) {
+ for (const name of shot.via) {
+ const b = await beat(shot.track, name).catch(() => null);
+ if (!b || !b.focus) continue;
+ const t = (b.at - from) / speed;
+ if (t <= 0 || t >= gesture) continue;
+ keys.push(key(t, b.focus, { ...limits, maxZoom: Math.max(limits.maxZoom, 1.6) }));
+ }
+ }
+ keys.push(B);
+ const M = shot.moveSeconds ?? 1.2;
+ filters.push(`crop=${W}:${H}:0:'${pathExpr(keys, (K) => K.c, M, 't')}'`);
+ filters.push(zoomPath(keys, M, W, fps, { width, height }));
+ } else if (kind === 'terminal') {
+ filters.push(fit);
+ } else if (kind === 'phone') {
+ filters.push(fit);
+ }
+ filters.push(`scale=${width}:${height}:force_original_aspect_ratio=decrease`, `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=#0f1419`, `fps=${fps}`);
+ const out = path.join(TMP, `${shot.id}.mp4`);
+ if (!same) {
+ await run('ffmpeg', ['-y', '-loglevel', 'error', '-ss', String(from), '-to', String(to), '-i', src, '-vf', filters.join(','), ...enc, out]);
+ } else {
+ const png = path.join(TMP, `${shot.id}.png`);
+ await run('ffmpeg', ['-y', '-loglevel', 'error', '-ss', String(from), '-i', src, '-frames:v', '1', png]);
+ const still = filters.filter((f) => !f.startsWith('tpad') && !f.startsWith('setpts')).join(',');
+ await run('ffmpeg', ['-y', '-loglevel', 'error', '-loop', '1', '-t', String(total), '-i', png, '-vf', still, ...enc, out]);
+ }
+ const len = await duration(out);
+ console.log(` ${shot.id.padEnd(4)} ${shot.track.padEnd(28)} ${String(shot.from).padEnd(16)}→ ${String(shot.to).padEnd(16)} ${gesture.toFixed(1)}s${speed !== 1 ? ' at ' + speed + '×' : ''}${hold ? ` + ${hold.toFixed(1)}s hold` : ''} = ${len.toFixed(2)}s${line ? ` line ${shot.line} ${line.seconds.toFixed(1)}s` : ''}`);
+ return { ...shot, file: out, len, lineTake: line };
+}
+
+const clips = [];
+for (const [i, entry] of cfg.shots.entries()) {
+ if (entry.card) { if (!only) clips.push(await cutCard(entry, i)); continue; }
+ if (only && !only.has(entry.id)) continue;
+ const prev = cfg.shots[i - 1];
+ clips.push(await cutShot(entry, prev && !prev.card ? prev : null));
+}
+if (browser) await browser.close();
+
+// ── the timeline ────────────────────────────────────────────────────────────────
+let t = 0; const timeline = [];
+for (const c of clips) { timeline.push({ id: c.id, in: Number(t.toFixed(3)), out: Number((t + c.len).toFixed(3)), line: c.line ?? null, lineAt: c.lineTake ? Number((t + (c.lineOffset ?? cfg.lineOffset)).toFixed(3)) : null, ...(c.caption ? { caption: c.caption } : {}) }); t += c.len; }
+const total = t;
+const list = path.join(TMP, 'concat.txt');
+await fs.writeFile(list, clips.map((c) => `file '${path.resolve(c.file)}'`).join('\n') + '\n');
+const silent = path.join(TMP, 'video.mp4');
+await run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]);
+
+// The closing card: faded in over the last shot's hold (slide.at seconds into it), before its line.
+let video = silent;
+if (cfg.slide && !only) {
+ const close = timeline.findLast((x) => x.id === 'Z1');
+ if (close) {
+ const png = path.join(TMP, 'slide.png');
+ browser = await chromium.launch();
+ await renderCard(cfg.slide, png);
+ await browser.close();
+ const fadeAt = close.in + (cfg.slide.at ?? 3.0), fade = cfg.slide.fade ?? 1.2;
+ const withSlide = path.join(TMP, 'video-slide.mp4');
+ await run('ffmpeg', ['-y', '-loglevel', 'error', '-i', silent, '-loop', '1', '-framerate', String(fps), '-t', String(total), '-i', png,
+ '-filter_complex', `[1]format=rgba,fade=t=in:st=${fadeAt.toFixed(3)}:d=${fade}:alpha=1[sl];[0][sl]overlay=0:0:format=auto[v]`, '-map', '[v]', ...enc, withSlide]);
+ video = withSlide;
+ }
+}
+
+// ── sound ───────────────────────────────────────────────────────────────────────
+const out = at(cfg.output.replace(/\.mp4$/, only ? '-part.mp4' : '.mp4'));
+const lines = timeline.filter((x) => x.lineAt != null).map((x) => ({ ...x, take: voice.get(x.line) }));
+if (noAudio || !lines.length) {
+ await run('ffmpeg', ['-y', '-loglevel', 'error', '-i', video, '-c', 'copy', '-movflags', '+faststart', out]);
+} else {
+ const inputs = ['-i', video];
+ const parts = [];
+ lines.forEach((l, i) => { inputs.push('-i', l.take.file); parts.push(`[${i + 1}:a]aresample=48000,aformat=channel_layouts=stereo,adelay=${Math.round(l.lineAt * 1000)}|${Math.round(l.lineAt * 1000)},apad[l${i}]`); });
+ // The chapter tone, once per card, at the card's start — the one sound the film adds
+ // that the interface does not make, and it marks structure rather than a gesture.
+ const toneFile = cfg.tone?.file ? at(cfg.tone.file) : null;
+ const cards = timeline.filter((x) => x.id.startsWith('card-'));
+ const hasTone = toneFile && cards.length && await fs.access(toneFile).then(() => true, () => false);
+ const tones = [];
+ if (hasTone) cards.forEach((c, j) => { const idx = inputs.length / 2; inputs.push('-i', toneFile); parts.push(`[${idx}:a]aresample=48000,aformat=channel_layouts=stereo,volume=${cfg.tone.gain ?? -6}dB,adelay=${Math.round((c.in + 0.3) * 1000)}|${Math.round((c.in + 0.3) * 1000)},apad[t${j}]`); tones.push(`[t${j}]`); });
+ parts.push(`${lines.map((_, i) => `[l${i}]`).join('')}${tones.join('')}amix=inputs=${lines.length + tones.length}:normalize=0:dropout_transition=0,atrim=0:${total.toFixed(3)},asplit=2[voice][key]`);
+ let mixIn = '[voice]';
+ const musicFile = cfg.music?.file ? at(cfg.music.file) : null;
+ const hasMusic = musicFile && await fs.access(musicFile).then(() => true, () => false);
+ if (hasMusic) {
+ const m = cfg.music; const idx = inputs.length / 2;
+ inputs.push('-stream_loop', '-1', '-i', musicFile);
+ parts.push(`[${idx}:a]aresample=48000,aformat=channel_layouts=stereo,atrim=0:${total.toFixed(3)},volume=${m.gain ?? -20}dB,afade=t=out:st=${(total - (m.fadeOut ?? 2)).toFixed(3)}:d=${m.fadeOut ?? 2}[m]`);
+ parts.push(`[m][key]sidechaincompress=threshold=0.02:ratio=8:attack=60:release=600:makeup=1[md]`);
+ parts.push(`[voice][md]amix=inputs=2:normalize=0:dropout_transition=0[mix]`);
+ mixIn = '[mix]';
+ } else {
+ parts.push(`[key]anullsink`);
+ }
+ parts.push(`${mixIn}loudnorm=I=-14:TP=-1.5:LRA=11,aresample=48000,afade=t=out:st=${(total - 1).toFixed(3)}:d=1[a]`);
+ await run('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, '-filter_complex', parts.join(';'), '-map', '0:v', '-map', '[a]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', out]);
+ console.log(` sound: ${lines.length} lines${hasMusic ? ', music ducked under them' : ', no music file yet'}, loudness −14 LUFS`);
+}
+
+// ── subtitles ───────────────────────────────────────────────────────────────────
+const srtTime = (s) => { const ms = Math.round(s * 1000); const h = Math.floor(ms / 3600000), m = Math.floor(ms % 3600000 / 60000), sec = Math.floor(ms % 60000 / 1000), r = ms % 1000; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')},${String(r).padStart(3, '0')}`; };
+const cues = [];
+for (const l of lines) {
+ // Cues break at sentence ends, timed from the alignment the voice take came with: the
+ // spoken text differs from the written one only in the respellings, so the sentence
+ // boundaries line up by count.
+ const al = await alignmentOf(l.take);
+ const chars = al.characters, starts = al.character_start_times_seconds, ends = al.character_end_times_seconds;
+ const spokenSentences = l.take.spoken.split(/(?<=[.!?])\s+/);
+ const writtenSentences = l.take.text.split(/(?<=[.!?])\s+/);
+ let pos = 0;
+ spokenSentences.forEach((s, i) => {
+ const start = pos, end = pos + s.length - 1;
+ const t0 = starts[Math.min(start, chars.length - 1)] ?? 0, t1 = ends[Math.min(end, chars.length - 1)] ?? l.take.seconds;
+ cues.push({ from: l.lineAt + t0, to: l.lineAt + t1, text: writtenSentences[i] ?? s });
+ pos = end + 1; while (pos < chars.length && chars[pos] === ' ') pos++;
+ });
+}
+// A shot's `caption` is a subtitle with no voice behind it — what the picture cannot show,
+// such as a step done off camera. It holds over the shot, inside the pause at either end.
+for (const c of timeline.filter((x) => x.caption)) cues.push({ from: c.in + cfg.lineOffset, to: c.out - cfg.linePad / 2, text: c.caption });
+cues.sort((a, b) => a.from - b.from);
+await fs.writeFile(out.replace(/\.mp4$/, '.srt'), cues.map((c, i) => `${i + 1}\n${srtTime(c.from)} --> ${srtTime(c.to)}\n${c.text}\n`).join('\n'));
+await fs.writeFile(out.replace(/\.mp4$/, '.marks.json'), JSON.stringify({ output: out, duration: Number(total.toFixed(3)), shots: timeline }, null, 2));
+await run('ffmpeg', ['-v', 'error', '-y', '-i', out, '-vf', 'fps=1/8,scale=384:-2,tile=6x8', '-frames:v', '1', '-update', '1', out.replace(/\.mp4$/, '-sheet.jpg')]);
+console.log(`→ ${path.relative(ROOT, out)} ${(total / 60).toFixed(1)} min, ${clips.length} clips, ${cues.length} subtitle cues`);
diff --git a/screencast/scenes/overview-0-graph.mjs b/screencast/scenes/overview-0-graph.mjs
new file mode 100644
index 00000000..78504853
--- /dev/null
+++ b/screencast/scenes/overview-0-graph.mjs
@@ -0,0 +1,81 @@
+// The overview's opening: an order in Graph mode, maximised to the whole frame, the camera
+// orbiting it while the force layout settles - the graph and nothing else, as the teaser
+// opened. Nothing is clicked on camera: the view's own Fullscreen button maximises the canvas
+// (the `graph-3d-maximized` class, client/graph3d.xsl) before the opening beat, the canvas's
+// controls and the pointer are hidden, and the motion is the camera's, driven through the
+// ForceGraph3D instance the app keeps at LinkedDataHub.graphs. Dark, recorded on the 2880×1800
+// viewport at zoom 1, where the canvas is native-sharp.
+//
+// Beats: 0.1-start when the graph is maximised, fitted and orbiting; 0.1-end where the first
+// intro line hands over to the second; 0.2-end after that.
+// Z1, the close, holds the 0.1-start frame.
+import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs';
+import { GEOMETRY_2X, load } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const ORDER = '10423';
+const OPENS_ON = `${base}/orders/${ORDER}/?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#GraphMode')}`;
+// two intro lines over one orbit: 0.1 to 0.1-end, 0.2 from there to 0.2-end
+const HOLD = 15_000;
+const HOLD2 = 17_000;
+// a full turn in this long reads as drift, not spin
+const TURN_SECONDS = 60;
+
+await runScene({
+ id: 'overview-0-graph', target: OPENS_ON, warm: OPENS_ON, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, marks }) {
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await load(page, OPENS_ON, 'canvas', 3000);
+ await page.locator('button.graph-3d-fullscreen').first().click();
+ await until(page.waitForFunction(() => !!document.querySelector('.graph-3d-maximized'), null, { timeout: 10_000 }), 'the graph maximised');
+ // The canvas's own controls, the library's navigation hint and the pointer go. The
+ // maximised canvas is held to the full height (a later `.content-body > .graph-3d-canvas`
+ // rule in ldh.css sets its height back to auto, 117 px short of the viewport), and the
+ // renderer is resized to match
+ await page.addStyleTag({ content: `
+ .graph-3d-show-panel, .graph-3d-info-panel, .graph-3d-tooltip,
+ .graph-3d-zoom, .graph-3d-fullscreen, .scene-nav-info { display: none !important; }
+ .graph-3d-canvas.graph-3d-maximized { height: 100vh !important; }
+ #__ldh-cursor { visibility: hidden !important; }` });
+ await sleep(500);
+
+ // fitted, then orbiting the graph's centre from a little inside the fitted distance, closing in
+ // over the hold; the loop keeps running until the take ends
+ const orbit = await page.evaluate(({ turn, hold }) => {
+ const entries = Object.entries(window.LinkedDataHub?.graphs ?? {});
+ const live = entries.filter(([id]) => document.getElementById(id)?.closest('.graph-3d-maximized')).at(-1) ?? entries.at(-1);
+ if (!live) return { ok: false };
+ const fg = live[1].instance ?? live[1];
+ // the page's own dark ground behind the graph, not the canvas's transparency
+ const ground = getComputedStyle(document.body).backgroundColor;
+ fg.width(innerWidth).height(innerHeight).backgroundColor(ground);
+ fg.zoomToFit(0, 40);
+ const box = fg.getGraphBbox();
+ const c = { x: (box.x[0] + box.x[1]) / 2, y: (box.y[0] + box.y[1]) / 2, z: (box.z[0] + box.z[1]) / 2 };
+ const cam = fg.cameraPosition();
+ const dx = cam.x - c.x, dz = cam.z - c.z;
+ const r0 = Math.hypot(dx, dz), a0 = Math.atan2(dx, dz), y = cam.y;
+ const start = performance.now();
+ const step = (now) => {
+ const t = (now - start) / 1000;
+ const a = a0 + (2 * Math.PI * t) / turn;
+ const r = r0 * (0.8 - 0.2 * Math.min(1, (t * 1000) / hold));
+ fg.cameraPosition({ x: c.x + r * Math.sin(a), y, z: c.z + r * Math.cos(a) }, c);
+ window.__ldhOrbit = requestAnimationFrame(step);
+ };
+ window.__ldhOrbit = requestAnimationFrame(step);
+ return { ok: true, nodes: fg.graphData().nodes.length };
+ }, { turn: TURN_SECONDS, hold: HOLD + HOLD2 });
+ if (!orbit.ok) throw new Error('no graph instance to orbit');
+ await sleep(1200);
+ await marks.beat('0.1-start', `the order as a graph, maximised and orbiting: ${orbit.nodes} nodes`);
+ await sleep(HOLD);
+ await marks.beat('0.1-end', 'still orbiting: the second intro line starts');
+ await sleep(HOLD2);
+ await marks.beat('0.2-end', 'closed in');
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
diff --git a/screencast/scenes/overview-a-browse.mjs b/screencast/scenes/overview-a-browse.mjs
new file mode 100644
index 00000000..5bcb4f89
--- /dev/null
+++ b/screencast/scenes/overview-a-browse.mjs
@@ -0,0 +1,108 @@
+// Flow A of the overview, the 2× half: the drawer opens on the document tree and a
+// container is opened from it (A1); the container's child listing is cycled through its
+// layout modes (A2); and the search dialog finds a resource by its label (A5). The graph
+// shots between them, A3 and A4, are overview-a-graph.mjs, recorded at 1× because the
+// canvas is only sharp unzoomed; the cut interleaves the two takes by their beats.
+//
+// Read-only: nothing is written. Dark, 2×, the pointer at a person's pace. Every shot
+// in the script has a -start and -end beat, each with the focus box of what the
+// gesture lands on, so the cutter can place the narration line at the start beat and
+// hold the shot to the end one.
+import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs';
+import { openTree, treeGo, searchGo } from '../lib/nav.mjs';
+import { switchViewMode, currentViewMode } from '../lib/modes.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTop } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const OPENS_ON = `${base}/`;
+const CONTAINER = 'Territories';
+
+await runScene({
+ id: 'overview-a-browse', target: OPENS_ON, warm: OPENS_ON, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await zoom2x(page);
+ await load(page, OPENS_ON, '.ldh-pane.is-active .ldh-block', 3000);
+
+ // ── A1 · the tree, and a container opened from it ───────────────────────────
+ await cursor.moveTo(900, 900, { duration: 400 });
+ await sleep(1500);
+ await marks.beat('A1-start', 'the dashboard; pointer heading for the left edge');
+ // The warmed page can still be re-rendering client-side when the edge is reached, which
+ // destroys the wait's execution context and reports the drawer as not opened. Ask again.
+ let opened = false;
+ for (let i = 0; i < 3 && !opened; i++) opened = await openTree(page, cursor);
+ if (!opened) throw new Error('A1: the drawer did not open');
+ await sleep(1200);
+ const drawer = ui(page).locator('.left-sidebar, .ldh-sidebar').first();
+ await marks.beat('A1-tree', 'the document tree, open', await focus(drawer));
+ // The tree opens on Root alone; its containers show once Root is expanded.
+ const expander = drawer.locator('button.btn-expand-tree').first();
+ const linksBefore = await ui(page).locator('.tree-link').count();
+ await cursor.click(expander);
+ await until(page.waitForFunction((n) => document.querySelectorAll('.ldh-pane.is-active .tree-link').length > n, linksBefore, { timeout: 20_000 }), 'the root\'s children in the tree');
+ await sleep(1500);
+ await marks.beat('A1-expanded', `${await ui(page).locator('.tree-link').count()} entries`, await focus(drawer));
+ const went = await treeGo(page, cursor, CONTAINER);
+ if (!went.ok) throw new Error(`A1: ${went.why}`);
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-view-toolbar, .ldh-pane.is-active .ldh-block', { timeout: 30_000 }), `the ${CONTAINER} container`);
+ await until(page.waitForSelector('.ldh-pane.is-active .ol-viewport', { timeout: 30_000 }), 'the territories map');
+ await sleep(2500);
+ await marks.beat('A1-end', `${CONTAINER}: ${page.url().replace(base, '')}`);
+ await sleep(600);
+
+ // ── A2 · the child listing, in every layout mode ────────────────────────────
+ const view = () => ui(page).locator('.ldh-block').filter({ has: page.locator('.ldh-view-toolbar') }).first();
+ await marks.beat('A2-start', `the listing as ${await currentViewMode(page) ?? 'its default'}`, await focus(view()));
+ for (const mode of ['list-mode', 'table-mode', 'grid-mode', 'map-mode']) {
+ const r = await switchViewMode(page, cursor, mode, { settle: 2600 });
+ if (r === false) throw new Error(`A2: ${mode} is not on offer`);
+ await marks.beat(`A2-${mode.replace('-mode', '')}`, `the listing as ${mode}`, await focus(view()));
+ }
+ await until(page.waitForSelector('.ldh-pane.is-active .ol-viewport', { timeout: 30_000 }), 'the map again');
+ await sleep(1500);
+ await marks.beat('A2-end', 'back on the map', await focus(view()));
+ await sleep(600);
+
+ // ── A5 · search by label ───────────────────────────────────────────────────
+ await easeScrollTop(page, { ms: 1200 }); await sleep(400);
+ await marks.beat('A5-start', 'pointer to the drawer, for search');
+ const found = await searchGo(page, cursor, 'Seattle', { match: 'Seattle' });
+ if (!found.ok) throw new Error(`A5: ${found.why}`);
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 20_000 }), 'the found document');
+ await sleep(2000);
+ await marks.beat('A5-end', `found: ${found.label.split('\n')[0]} (${found.total} results) → ${page.url().replace(base, '')}`);
+ await sleep(800);
+
+ // ── A6 · any URL in the address bar: a web page's JSON-LD, read as data ──────
+ const FILM = 'https://www.rottentomatoes.com/m/star_wars_episode_iv_a_new_hope';
+ const box = page.locator('form.ldh-address input[name="uri"]').first();
+ await cursor.click(box);
+ await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
+ await page.keyboard.press('Backspace');
+ await sleep(300);
+ // framed on the bar: the line says the URL goes there, so the cut has to keep it in view
+ await marks.beat('A6-start', 'the address bar, cleared', await focus(box));
+ await box.pressSequentially(FILM, { delay: 18 });
+ await sleep(600);
+ await marks.beat('A6-typed', 'a film\'s page on a review site, typed', await focus(box));
+ await page.keyboard.press('Enter');
+ await until(page.waitForFunction(() => /Star Wars/.test(document.querySelector('.ldh-pane.is-active')?.innerText ?? ''), null, { timeout: 60_000 }), 'the film, rendered through the proxy');
+ await sleep(3500);
+ const film = () => ui(page).locator('.ldh-block').first();
+ await marks.beat('A6-rendered', 'the film as a resource: a Movie, with its people as links', await focus(film()));
+ const actor = ui(page).locator('.ldh-block a').filter({ hasText: /harrison ford/i }).first();
+ if (!(await actor.count())) throw new Error('A6: no actor link on the film');
+ await actor.scrollIntoViewIfNeeded();
+ await cursor.moveTo(...(await centre(actor)), { duration: 600 });
+ await marks.beat('A6-link', 'pointer on an actor\'s link', await focus(film()));
+ await cursor.click(actor);
+ await until(page.waitForFunction(() => /Harrison Ford/.test(document.querySelector('.ldh-pane.is-active')?.innerText ?? '') && /Person/.test(document.querySelector('.ldh-pane.is-active')?.innerText ?? ''), null, { timeout: 60_000 }), 'the actor, rendered through the proxy');
+ await sleep(3000);
+ await marks.beat('A6-end', 'the actor: a Person, read from another page of the same site', await focus(ui(page).locator('.ldh-block').first()));
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
diff --git a/screencast/scenes/overview-a-graph.mjs b/screencast/scenes/overview-a-graph.mjs
new file mode 100644
index 00000000..e9f4008d
--- /dev/null
+++ b/screencast/scenes/overview-a-graph.mjs
@@ -0,0 +1,99 @@
+// Flow A of the overview, the graph half, at 1×: an order in Graph mode, a node expanded
+// across a document boundary and that node opened (A3); then, on the opened document,
+// a view's Related results pivoting to a related class (A4). The route is the supercut
+// graph take's — the customer node of order 10423, the customer's orders, the reps who
+// took them — because it is the one that reads on camera. Recorded on a 2880×1800
+// viewport at zoom 1, so the canvas is native-sharp; the cutter pushes into each beat's
+// focus box for the chrome.
+//
+// Read-only: nothing is written.
+import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs';
+import { foreignNode, expand, select, zoomToFit, approach } from '../lib/graph.mjs';
+import { revealControls } from '../lib/blocks.mjs';
+import { GEOMETRY_2X, focus, centre, load } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const ORDER = '10423';
+const OPENS_ON = `${base}/orders/${ORDER}/?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#GraphMode')}`;
+
+await runScene({
+ id: 'overview-a-graph', target: OPENS_ON, warm: OPENS_ON, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await load(page, OPENS_ON, 'canvas', 3000);
+ await zoomToFit(page, cursor);
+ await sleep(2500);
+
+ // ── A3 · a node expanded across a document boundary, then opened ───────────
+ const node = await foreignNode(page, `${base}/orders/${ORDER}/`, { skip: 0, prefer: /\/customers\// });
+ if (!node || node.error) throw new Error(`A3: ${node?.error ?? 'no customer node'}`);
+ const at = await approach(page, cursor, node.id, { fallback: node });
+ const around = (p, r) => ({ focus: { x: Math.round(p.x - r), y: Math.round(p.y - r * 0.625), w: r * 2, h: Math.round(r * 1.25) } });
+ await marks.beat('A3-start', 'the order as a graph; pointer on the customer node', around(at, 520));
+ const grown = await expand(page, cursor, node, { settle: 3500 });
+ const at2 = await approach(page, cursor, node.id, { fallback: at });
+ await marks.step('A3-grown', grown, (x) => `+${x.gained} nodes from the customer's own document`, around(at2, 760));
+ await sleep(700);
+ await zoomToFit(page, cursor);
+ await sleep(1500);
+ const picked = await select(page, cursor, { ...node });
+ if (!picked.ok) throw new Error(`A3: ${picked.why}`);
+ const panel = page.locator('[id^="info-content-"]').first();
+ await marks.beat('A3-selected', 'the node selected; its link in the panel', await focus(panel));
+ const before = page.url().split('?')[0];
+ if (picked.point) await cursor.clickAt(picked.point.x, picked.point.y, { duration: 500, settle: 150, after: 200 }); else await picked.link.click();
+ await until(page.waitForFunction((b) => location.href.split('?')[0] !== b, before, { timeout: 20_000 }), 'the customer\'s record');
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 20_000 }), 'the customer\'s record, rendered');
+ await sleep(2000);
+ await marks.beat('A3-end', `opened: ${page.url().replace(base, '')}`);
+ await sleep(600);
+
+ // ── A4 · Related results: the orders' brokers ──────────────────────────────
+ const view = () => page.locator('.ldh-pane.is-active .ldh-block').filter({ has: page.locator('.ldh-view-toolbar') }).filter({ hasText: 'Orders from this customer' }).first();
+ const count = async () => (await view().locator('.count b').first().textContent({ timeout: 4000 })).trim();
+ const toggle = view().locator('.ldh-block-head button.tb-controls').first();
+ if (!(await toggle.count())) throw new Error('A4: no controls toggle on the orders view');
+ await toggle.scrollIntoViewIfNeeded();
+ await cursor.moveTo(...(await centre(toggle)), { duration: 600 });
+ await marks.beat('A4-start', `${await count()} orders; pointer on the controls toggle`, await focus(view()));
+ const shown = await revealControls(page, cursor, view());
+ if (!shown.ok) throw new Error(shown.why ?? 'A4: the controls did not open');
+ const bar = view().locator('.ldh-pivot-bar').first();
+ if (!(await bar.count())) throw new Error('A4: no Related results bar on the orders view');
+ await marks.beat('A4-open', 'the toolbar: facets, sort, and Related results, opened', await focus(view()));
+ // A facet first: the order-status pill opens its values with their counts, and the
+ // delivered orders are picked, so the view filters before it pivots.
+ const facet = view().locator('.ldh-view-toolbar .facet-pill').filter({ hasText: /status/i }).first();
+ if (!(await facet.count())) throw new Error('A4: no Order status facet on the orders view');
+ await cursor.moveTo(...(await centre(facet)), { duration: 500 });
+ await marks.beat('A4-facet', 'pointer on the Order status facet', await focus(view()));
+ await cursor.click(facet);
+ const pop = page.locator('.facet-pop:visible').first();
+ await until(pop.waitFor({ state: 'visible', timeout: 15_000 }), 'the facet\'s values');
+ await until(pop.locator('button.opt').first().waitFor({ state: 'visible', timeout: 20_000 }), 'the facet\'s value counts');
+ await sleep(1500);
+ await marks.beat('A4-values', 'the status values, each with its count', await focus(view(), pop));
+ const opt = pop.locator('button.opt').filter({ hasText: /Delivered/ }).first();
+ const chosen = (await opt.count()) ? opt : pop.locator('button.opt').first();
+ const was0 = await count();
+ await cursor.click(chosen);
+ await until(page.waitForFunction((b) => { const c = document.querySelector('.ldh-pane.is-active .count b'); return c && c.textContent.trim() !== b; }, was0, { timeout: 20_000 }), 'the filtered results');
+ await sleep(1500);
+ await page.keyboard.press('Escape').catch(() => {});
+ await sleep(600);
+ await marks.beat('A4-filtered', `${await count()} of ${was0}: the delivered orders`, await focus(view()));
+ const pill = view().locator('.ldh-pivot-pill:visible').filter({ hasText: /Sales rep/ }).first();
+ if (!(await pill.count())) throw new Error('A4: no Sales rep pill');
+ await pill.scrollIntoViewIfNeeded();
+ const was = await count();
+ await cursor.moveTo(...(await centre(pill)), { duration: 500 });
+ await marks.beat('A4-pill', 'pointer on the Sales rep pill', await focus(view()));
+ await pill.click();
+ await until(page.waitForFunction((b) => { const c = document.querySelector('.ldh-pane.is-active .count b'); return c && c.textContent.trim() !== b; }, was, { timeout: 20_000 }), 'the related results');
+ await sleep(2000);
+ await marks.beat('A4-end', `${await count()} — the reps who took them`, await focus(view()));
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
diff --git a/screencast/scenes/overview-b-author.mjs b/screencast/scenes/overview-b-author.mjs
new file mode 100644
index 00000000..d952ae5a
--- /dev/null
+++ b/screencast/scenes/overview-b-author.mjs
@@ -0,0 +1,217 @@
+// Flow B of the overview — authoring, in one continuous take on a scratch document: a
+// SELECT typed and saved, its results rendering (B1); a chart bound to it from its own
+// block (B2); Content mode, a sentence typed in an XHTML block (B3); a word in it
+// annotated with a link to the chart (B4); the chart embedded as an object block and
+// dragged above the sentence (B5). The supercut's compose take, with the annotation
+// added and the beats named after the script's shots.
+//
+// Dark, 2×, the pointer at a person's pace. The scratch document is reset off camera.
+import { runScene, resolve, geometryFrom, sleep, must, until } from '../lib/harness.mjs';
+import { resetDocument, removeAll } from '../lib/fixture.mjs';
+import { create, typeQuery, fill, save, configureChart } from '../lib/constructors.mjs';
+import { addProse, addObject, revealControls, copyUri } from '../lib/blocks.mjs';
+import { dragBlock } from '../lib/editing.mjs';
+import { annotate } from '../lib/annotate.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTo, easeScrollTop, zoomToFitRows } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const TITLE = 'Reps by region';
+const SENTENCE = 'Eastern has four reps on nineteen territories; the chart shows the split.';
+const QUERY = `PREFIX schema:
+
+SELECT ?region (COUNT(DISTINCT ?rep) AS ?reps)
+WHERE {
+GRAPH ?g {
+?rep schema:areaServed ?territory .
+}
+GRAPH ?h {
+?territory schema:containedInPlace ?place .
+}
+GRAPH ?i {
+?place schema:name ?region .
+}
+}
+GROUP BY ?region
+ORDER BY DESC(?reps)`;
+const doc = await resetDocument({ ldh: opts.ldh, base, certFile: opts.certFile, certPassword: opts.certPassword, certPasswordFile: opts.certPasswordFile, container: base.endsWith('/') ? base : base + '/', slug: 'overview-compose', title: 'Coverage' });
+const READ = `${doc.url}?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#ReadMode')}`;
+console.log('page:', doc.url);
+
+await runScene({
+ id: 'overview-b-author', target: READ, warm: READ, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, type, marks }) {
+ await zoom2x(page);
+ await load(page, READ, '.ldh-pane.is-active .ldh-view-toolbar, .ldh-pane.is-active .ldh-block, .ldh-pane.is-active button', 2500);
+
+ // ── B1 · a SELECT, typed and saved; its results ────────────────────────────
+ const cs = await create(page, cursor, 'SELECT');
+ if (!cs.ok) throw new Error(cs.why);
+ const editor = ui(page).locator('.CodeMirror').first();
+ const form = editor.locator('xpath=ancestor::form[1]');
+ const q = await typeQuery(page, cursor, QUERY, {
+ onAttempt: async (n) => marks.beat('B1-start', `an empty query form (attempt ${n})`, await focus(form)),
+ onTyped: async () => marks.beat('B1-typed', 'the last character in', await focus(form)),
+ });
+ await marks.step('B1-verified', q, 'the query, read back', await focus(form));
+ const saveBtn = form.locator('button.btn-save, button[class*="btn-save"]').filter({ visible: true }).last();
+ // the create bar is sticky at the window's foot: Save is brought to rest above it, not under it
+ await easeScrollTo(saveBtn, { ms: 2000, block: 'end', margin: 260 }).catch(() => {});
+ await sleep(300);
+ await marks.beat('B1-scrolled', 'the form\'s foot in view: Title, Save', await focus(saveBtn));
+ must(await fill(page, cursor, 'Title', TITLE), 'B1 title');
+ await cursor.moveTo(...(await centre(saveBtn)), { duration: 600 });
+ await marks.beat('B1-save', 'pointer on Save', await focus(saveBtn));
+ await cursor.click(saveBtn);
+ await marks.beat('B1-saved', 'saved');
+ const row = () => ui(page).locator('.ldh-block-row').filter({ hasText: TITLE }).first();
+ await until(row().locator('table tbody tr').first().waitFor({ state: 'visible', timeout: 30_000 }), 'the saved query\'s rows');
+ await easeScrollTo(row(), { ms: 1400, margin: 40 }); await sleep(600);
+ await marks.beat('B1-end', 'the query block: Run, and the rows beneath', await focus(row()));
+ await sleep(600);
+
+ // ── B2 · a chart bound to it ───────────────────────────────────────────────
+ await marks.beat('B2-start', 'the rows; chart type: Table', await focus(row()));
+ const shown = await revealControls(page, cursor, row());
+ if (!shown.ok) throw new Error(shown.why ?? 'the chart controls did not open');
+ const cfg = await configureChart(page, cursor, row(), { type: 'Bar chart', category: 'region', series: ['reps'] });
+ await sleep(1200);
+ await marks.step('B2-drawn', cfg, 'bars in the pane', await focus(row()));
+ const createChart = row().locator('button.ac-btn.in-primary').filter({ hasText: /^Create$/ }).first();
+ await createChart.evaluate((el) => el.scrollIntoView({ block: 'center' })); await sleep(500);
+ await cursor.click(createChart); await sleep(2000);
+ must(await fill(page, cursor, 'Title', TITLE), 'B2 title');
+ const s2 = await save(page, cursor);
+ await sleep(600);
+ const chartRow = () => ui(page).locator('.ldh-block-row').filter({ hasText: TITLE }).last();
+ await easeScrollTo(chartRow(), { ms: 1400, margin: 40 }).catch(() => {}); await sleep(600);
+ await marks.step('B2-end', s2, 'bars, saved as a chart', await focus(chartRow()));
+ await sleep(600);
+
+ // The chart's URI, for the annotation later: copied from its own block, never typed.
+ // The block the save renders back is a degraded render — its copy control writes an
+ // empty string, where the same control on a loaded document writes the URI (and the
+ // graph take found the same render labelling rows by local name). A load of the
+ // document has the real block; it sits here, at the shot boundary, where the cut is.
+ await page.reload({ waitUntil: 'load' });
+ await until(chartRow().waitFor({ state: 'visible', timeout: 30_000 }), 'the chart block, reloaded');
+ await sleep(1500);
+ // Clear of the sticky header, or the pointer's click on the hover-revealed control
+ // lands on the header instead (measured: the block head scrolled to y=162 at 2×).
+ await easeScrollTo(chartRow(), { ms: 1200, margin: 260 }).catch(() => {}); await sleep(500);
+ let copied = { ok: false, value: null };
+ for (let i = 0; i < 3 && !(copied.value && copied.value.startsWith('http')); i++) {
+ await chartRow().hover(); await sleep(800);
+ copied = await copyUri(page, cursor, chartRow());
+ if (!copied.ok) throw new Error(`B4 prep: ${copied.why}`);
+ if (!(copied.value && copied.value.startsWith('http'))) await sleep(1500);
+ }
+ if (!(copied.value && copied.value.startsWith('http'))) throw new Error(`B4 prep: the copy control put "${copied.value ?? ''}" on the clipboard`);
+ console.log(' chart URI copied:', copied.value.replace(base, ''));
+ await sleep(400);
+
+ // ── B3 · Content mode: a sentence, typed ───────────────────────────────────
+ const xhtml = () => ui(page).locator('button.create-action.add-constructor').filter({ hasText: 'XHTML' }).first();
+ const toggle = ui(page).locator('button.layout-modes.drop-toggle, button[title="Mode"]').first();
+ await easeScrollTop(page, { ms: 1400 }); await sleep(600);
+ const tb = await toggle.boundingBox();
+ const menuBox = tb ? { focus: { x: tb.x - 420, y: Math.max(0, tb.y - 30), w: tb.width + 520, h: 560 } } : null;
+ await marks.beat('B3-start', 'Properties mode; pointer to the mode toggle', menuBox);
+ const item = ui(page).locator('.modes-pop a.mi.content-mode').first();
+ for (let i = 0; i < 3 && !(await item.isVisible().catch(() => false)); i++) { await cursor.click(toggle); await sleep(700); }
+ if (!(await item.isVisible().catch(() => false))) throw new Error('B3: the mode menu did not open');
+ await cursor.click(item);
+ await until(xhtml().waitFor({ state: 'visible', timeout: 20_000 }), 'Content mode (the XHTML button)');
+ await sleep(500);
+ await marks.beat('B3-content', 'Content mode, an empty page', await focus(xhtml()));
+ const p1 = await addProse(page, cursor, type, SENTENCE, { onOpen: async (editable) => { await sleep(400); await marks.beat('B3-editor', 'the editor, open and empty', await focus(editable)); } });
+ await sleep(800);
+ const prose = () => ui(page).locator('.ldh-block-row').first();
+ await marks.step('B3-end', p1, 'the sentence, on the page', await focus(prose()));
+ await sleep(600);
+
+ // ── B4 · a word linked to a resource ───────────────────────────────────────
+ await prose().hover(); await sleep(600);
+ const pencil = prose().locator('button.ac-iconbtn').filter({ hasText: 'edit' }).last();
+ await marks.beat('B4-start', 'pointer to the block\'s edit pencil', await focus(prose()));
+ await cursor.click(pencil);
+ await sleep(3000);
+ // The mode switch navigated since the copy; make sure what was copied is still what
+ // the paste will find (the clipboard is the page's, and a navigation can reset it).
+ await page.evaluate((v) => navigator.clipboard.writeText(v), copied.value);
+ const an = await annotate(page, cursor, type, { word: 'chart', property: 'subject' });
+ if (!an.ok) throw new Error(`B4: ${an.why}`);
+ // The save re-renders the block; the beat waits for the row to be back with the word
+ // linked, and brings it into the frame, or it is marked over a spinner.
+ const linked = prose().locator('[property], [rel], a').filter({ hasText: 'chart' }).first();
+ await until(linked.waitFor({ state: 'visible', timeout: 30_000 }), 'the block re-rendered with the linked word');
+ await prose().evaluate((el) => el.scrollIntoView({ block: 'center' })); await sleep(1200);
+ await marks.beat('B4-end', `"chart" linked with ${an.property} to ${an.uri.replace(base, '')}`, await focus(prose()));
+ await sleep(600);
+
+ // ── B5 · the chart embedded, and dragged above the sentence ────────────────
+ await marks.beat('B5-start', 'pointer on + Object', await focus(ui(page).locator('button.create-action.add-constructor').filter({ hasText: 'Object' }).first()));
+ const o1 = await addObject(page, cursor, type, null, { label: TITLE, kind: 'Result set chart' });
+ await until(ui(page).locator('.ldh-block-row svg').first().waitFor({ state: 'visible', timeout: 15_000 }), 'the embedded chart');
+ await sleep(1200);
+ await marks.step('B5-embedded', o1, 'the chart, inside the page', await focus(ui(page).locator('.ldh-block-row').filter({ has: page.locator('svg') }).first()));
+ const rows = ui(page).locator('.ldh-block-row').filter({ has: page.locator('span.ldh-bh-drag') });
+ const proseRow = rows.first(), chart = rows.last();
+ await easeScrollTop(page, { ms: 1400 }); await sleep(500);
+ const fitted = await zoomToFitRows(page, [proseRow, chart]);
+ await marks.step('B5-fitted', fitted, (f) => `sentence above, chart below, at ${f.zoom.toFixed(2)}×`, await focus(proseRow, chart));
+ const dr = await dragBlock(page, cursor, proseRow, chart);
+ await sleep(1500);
+ await marks.step('B5-dropped', dr, 'dropped: the sentence now follows the chart', await focus(rows.first(), rows.last()));
+ await sleep(600);
+ await marks.step('B5-end', dr, 'chart above, sentence below', await focus(rows.first(), rows.last()));
+ await sleep(800);
+
+ // ldh.css sizes the page column at min-height: 100vh so a short page parks the create bar and the
+ // footer at the window's foot. Under the rig's CSS zoom that is two windows tall, and the bar sits a
+ // screen below the content. Halved from here on, it is one window at 2×, as a reader sees it; only
+ // from here, since the takes before it were framed on the page as it was
+ await page.addStyleTag({ content: '#visible-body { min-height: 50vh !important; }' });
+ // ── B5b · a video, embedded: its metadata read as RDF on the fly ───────────
+ const VIDEO = 'https://www.youtube.com/watch?v=OM6XIICm_qo'; // Tim Berners-Lee: The next Web of open, linked data (TED)
+ // Back at 2× (a re-layout, instant, at the beat boundary the cut lands on). The create bar is
+ // under the sentence and on screen already, so the page only moves if it is not
+ await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); await sleep(400);
+ const objBtn = ui(page).locator('button.create-action.add-constructor').filter({ hasText: 'Object' }).first();
+ const offScreen = (loc) => loc.evaluate((el) => { const r = el.getBoundingClientRect(); return r.top < 300 || r.bottom > window.innerHeight - 40; });
+ if (await offScreen(objBtn)) { await easeScrollTo(objBtn, { ms: 1400, block: 'end', margin: 160 }); await sleep(400); }
+ await cursor.moveTo(...(await centre(objBtn)), { duration: 600 });
+ await marks.beat('B5b-start', 'pointer on + Object', await focus(objBtn));
+ await cursor.click(objBtn);
+ const vform = page.locator('form:visible').filter({ hasText: /Value/ }).last();
+ await until(vform.waitFor({ state: 'visible', timeout: 15_000 }), 'the object block form');
+ await sleep(400);
+ // the form opens where the create bar was; if it runs off screen the page eases it into view, with
+ // the sentence still above it, so the video drawn in its place after Save needs no scroll of its own
+ if (await offScreen(vform)) { await easeScrollTo(vform, { ms: 1200, margin: 420 }); }
+ await sleep(600);
+ const vinput = vform.locator('.ldh-prop-group').filter({ hasText: 'Value' }).first().locator('input:not([type=hidden]):visible').first();
+ await cursor.click(vinput);
+ await page.keyboard.type(VIDEO, { delay: 18 });
+ await sleep(1200);
+ await marks.beat('B5b-typed', 'a video\'s link as the value', await focus(vform));
+ await page.keyboard.press('Escape').catch(() => {});
+ const vsave = vform.locator('button').filter({ hasText: /Save/ }).last();
+ await cursor.moveTo(...(await centre(vsave)), { duration: 500 });
+ await cursor.click(vsave);
+ await until(ui(page).locator('iframe').first().waitFor({ state: 'visible', timeout: 30_000 }), 'the video card');
+ await sleep(3000);
+ const vrow = ui(page).locator('.ldh-block-row').filter({ has: page.locator('iframe') }).first();
+ if (await offScreen(vrow)) await easeScrollTo(vrow, { ms: 1400, margin: 300 });
+ await sleep(1200);
+ await marks.beat('B5b-end', 'the video, a VideoObject with its title, on the page', await focus(vrow));
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
+
+// The scratch document goes once the take is in the can: flow A's tree shot lists the
+// root's children, and a leftover "Coverage" there is the rig showing.
+console.log(' scratch removed:', JSON.stringify(await removeAll({ ldh: opts.ldh, certFile: opts.certFile, certPassword: opts.certPassword, certPasswordFile: opts.certPasswordFile, urls: [doc.url] })));
diff --git a/screencast/scenes/overview-c-data.mjs b/screencast/scenes/overview-c-data.mjs
new file mode 100644
index 00000000..0edda70d
--- /dev/null
+++ b/screencast/scenes/overview-c-data.mjs
@@ -0,0 +1,150 @@
+// Flow C of the overview — bringing data in: a remote RDF document copied into the
+// dataspace with Actions ▸ Save as (C1); the CSV import form filled from the resources
+// the demo ships, the uploaded CSV and the CONSTRUCT that maps it — and not saved,
+// because saving would import the products a second time (C2); a photograph dropped on
+// a document and uploaded, then a Turtle file dropped on another and imported into it
+// (C3).
+//
+// Writes only into this run's scratch documents, which are reset off camera.
+import fs from 'node:fs/promises';
+import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs';
+import { resetDocument, removeAll } from '../lib/fixture.mjs';
+import { createFromDock } from '../lib/constructors.mjs';
+import { pickByLabel, switchDocumentMode } from '../lib/blocks.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTo } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const root = base.endsWith('/') ? base : base + '/';
+const creds = { ldh: opts.ldh, base, certFile: opts.certFile, certPassword: opts.certPassword, certPasswordFile: opts.certPasswordFile };
+const fork = await resetDocument({ ...creds, container: root, slug: 'overview-fork', title: 'A concept from UNESCO' });
+const photo = await resetDocument({ ...creds, container: root, slug: 'overview-photo', title: 'Dana Whitfield' });
+const turtle = await resetDocument({ ...creds, container: root, slug: 'overview-turtle', title: 'A dropped territory' });
+const REMOTE = 'https://unesco-thesaurus.demo.linkeddatahub.com/concepts/concept3683/';
+const read = (url) => `${url}?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#ReadMode')}`;
+
+// A file as a DataTransfer the page built itself, so the drag events carry a real File.
+async function transferOf(page, { name, mime, bytes }) {
+ const b64 = Buffer.from(bytes).toString('base64');
+ return page.evaluateHandle(({ name, mime, b64 }) => {
+ const bin = atob(b64); const arr = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
+ const t = new DataTransfer();
+ t.items.add(new File([arr], name, { type: mime }));
+ return t;
+ }, { name, mime, b64 });
+}
+
+async function drop(page, cursor, marks, id, transfer, note) {
+ const { width: vw, height: vh } = page.viewportSize();
+ await cursor.moveTo(vw * 0.5, vh * 0.55, { duration: 900 });
+ await marks.beat(`${id}-file`, `${note} in hand, over the document`);
+ await page.dispatchEvent('body', 'dragenter', { dataTransfer: transfer });
+ await page.locator('#file-drop').waitFor({ timeout: 10_000 });
+ await cursor.moveTo(vw * 0.56, vh * 0.47, { duration: 2600 });
+ await marks.beat(`${id}-drag`, `${note} over the drop overlay`);
+ await page.dispatchEvent('#file-drop', 'drop', { dataTransfer: transfer });
+}
+
+await runScene({
+ id: 'overview-c-data', target: read(fork.url), warm: read(fork.url), identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, type, marks }) {
+ await zoom2x(page);
+
+ // ── C1 · a remote document, copied in ──────────────────────────────────────
+ await load(page, read(fork.url), '.ldh-pane.is-active .ldh-block, .ldh-pane.is-active button', 2500);
+ await marks.beat('C1-start', 'an empty document; pointer to Actions');
+ await cursor.click(ui(page).locator('button.drop-toggle').filter({ hasText: 'Actions' }).first());
+ await sleep(900);
+ const saveAs = ui(page).locator('button.btn-save-as').first();
+ if (!(await saveAs.isVisible().catch(() => false))) throw new Error('C1: no Save as in the Actions menu');
+ await marks.beat('C1-menu', 'the Actions menu, Save as in it');
+ await cursor.click(saveAs);
+ const modal = page.locator('.ac-modal:visible').last();
+ await modal.waitFor({ state: 'visible', timeout: 12000 });
+ await sleep(1500);
+ await marks.beat('C1-form', 'Source and Graph', await focus(modal));
+ const source = modal.locator('.ldh-prop-group').filter({ hasText: 'Source' }).first().locator('input[type=text]:visible').first();
+ await cursor.click(source);
+ await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
+ await page.keyboard.press('Backspace');
+ await sleep(300);
+ await type(source, REMOTE, { base: 18, spread: 6 });
+ await sleep(1200);
+ await marks.beat('C1-source', 'the remote document as Source', await focus(modal));
+ await cursor.click(modal.locator('button').filter({ hasText: /Save/ }).last());
+ // the copy renders in place within a second of Save: no fixed wait and no reload, which
+ // held the document's own block on screen for ~13 s and showed the breadcrumb reloading.
+ // The copy's first block is that document (creator, container, primary topic); the
+ // concept it was made for is the block below it, typed Concept, so the shot ends there.
+ const concept = ui(page).locator('.ldh-block').filter({ has: page.locator('.ldh-type-chip', { hasText: 'Concept' }) }).first();
+ await until(concept.waitFor({ state: 'visible', timeout: 30_000 }), 'the copied Concept block');
+ const text = await page.locator('.ldh-pane.is-active').first().innerText().catch(() => '');
+ if (!/beverage|boisson|concept|skos/i.test(text)) throw new Error('C1: the local document does not describe the remote concept');
+ await sleep(1200);
+ await marks.beat('C1-saved', 'the copy, rendered in place');
+ await easeScrollTo(concept, { ms: 1400, margin: 200 });
+ await sleep(4000);
+ await marks.beat('C1-end', 'the copy: the concept, described locally', await focus(concept));
+ await sleep(800);
+
+ // ── C2 · the CSV import form ───────────────────────────────────────────────
+ await load(page, `${base}/products/`, '.ldh-pane.is-active .ldh-block', 2500);
+ await switchDocumentMode(page, cursor, 'read-mode');
+ await sleep(1200);
+ await marks.beat('C2-start', 'the products container; pointer to Create');
+ const made = await createFromDock(page, cursor, 'CSV import');
+ if (!made.ok) throw new Error(`C2: ${made.why}`);
+ await sleep(1800);
+ const form = page.locator('form:visible').filter({ hasText: 'Delimiter' }).last();
+ const group = (label) => form.locator('.ldh-prop-group').filter({ hasText: label }).first();
+ await marks.beat('C2-form', 'the CSV import form', await focus(form));
+ // the constructor fills the delimiter (ldh:delimiter "," in ldh.ttl); typing one more made it ",,"
+ const delimiter = await group('Delimiter').locator('input[type=text]:visible').first().inputValue();
+ if (delimiter !== ',') throw new Error(`C2: the delimiter is ${JSON.stringify(delimiter)}, not ","`);
+ const filePick = await pickByLabel(page, cursor, type, group('File').locator('input[type=text]:visible').first(), 'Products', { kind: 'File' });
+ if (!filePick.ok) throw new Error(filePick.why ?? 'C2: the CSV file was not offered');
+ await sleep(900);
+ await marks.beat('C2-file', 'the uploaded CSV picked as File', await focus(form));
+ const queryPick = await pickByLabel(page, cursor, type, group('Query').locator('input[type=text]:visible').first(), 'Products', { kind: 'CONSTRUCT' });
+ if (!queryPick.ok) throw new Error(queryPick.why ?? 'C2: the mapping query was not offered');
+ await sleep(900);
+ await marks.beat('C2-query', 'the CONSTRUCT that maps a row picked as Query', await focus(form));
+ await type(group('Title').locator('input[type=text]:visible').first(), 'Products import');
+ await sleep(2500);
+ await marks.beat('C2-end', 'the form, ready to run (not saved: it would import the products twice)', await focus(form));
+ await page.keyboard.press('Escape');
+ await sleep(1200);
+
+ // ── C3 · a file dropped: a photo uploaded, then Turtle imported ────────────
+ await load(page, read(photo.url), '.ldh-pane.is-active .ldh-block, .ldh-pane.is-active button', 2500);
+ await marks.beat('C3-start', 'a person\'s document, no picture yet');
+ const portrait = await transferOf(page, { name: 'dana.jpg', mime: 'image/jpeg', bytes: await fs.readFile(new URL('../fixtures/dana.jpg', import.meta.url)) });
+ await drop(page, cursor, marks, 'C3-photo', portrait, 'the photograph');
+ await until(page.waitForURL(/ReadMode/, { timeout: 30_000 }), 'the document reloading after the upload');
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 20_000 }), 'the document, rendered after the upload');
+ await sleep(2500);
+ const fileBlock = ui(page).locator('.ldh-block').filter({ hasText: 'dana.jpg' }).first();
+ if (!(await fileBlock.count())) throw new Error('C3: the drop did not upload dana.jpg');
+ await marks.beat('C3-uploaded', 'dropped: uploaded into the document', await focus(fileBlock));
+ await sleep(1200);
+
+ await load(page, read(turtle.url), '.ldh-pane.is-active .ldh-block, .ldh-pane.is-active button', 2500);
+ await marks.beat('C3-turtle-start', 'an empty document for a Turtle file');
+ // a whole territory, as the model requires of a schema:City (ns.ttl: name, identifier, region,
+ // latitude, longitude) — Rockville's own row from territories.csv; a partial one is refused
+ const ttl = `@prefix schema: .\n@prefix geo: .\n@prefix xsd: .\n<#this> a schema:City ; schema:name "Rockville" ; schema:identifier "20852" ; schema:description "A territory dropped in as Turtle." ;\n schema:containedInPlace <${base}/regions/1/#this> ; geo:lat "39.0496"^^xsd:float ; geo:long "-77.1204"^^xsd:float .\n`;
+ const rdf = await transferOf(page, { name: 'rockville.ttl', mime: 'text/turtle', bytes: Buffer.from(ttl) });
+ await drop(page, cursor, marks, 'C3-turtle', rdf, 'the Turtle file');
+ await until(page.waitForFunction(() => /Rockville/.test(document.querySelector('.ldh-pane.is-active')?.innerText ?? ''), null, { timeout: 30_000 }), 'the dropped data on the page');
+ await sleep(2500);
+ await marks.beat('C3-end', 'dropped: the Turtle imported into the document, rendered', await focus(ui(page).locator('.ldh-block').filter({ hasText: 'Rockville' }).first()));
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
+
+// The three scratch documents go once the take is in the can (see overview-b-author).
+console.log(' scratch removed:', JSON.stringify(await removeAll({ ...creds, urls: [fork.url, photo.url, turtle.url] })));
diff --git a/screencast/scenes/overview-d-versions.mjs b/screencast/scenes/overview-d-versions.mjs
new file mode 100644
index 00000000..688e2d38
--- /dev/null
+++ b/screencast/scenes/overview-d-versions.mjs
@@ -0,0 +1,124 @@
+// Flow D of the overview — versions: the History dialog on a document written three
+// times, each version with the agent who wrote it (D1); two versions compared, the
+// diff marked on the document, and an earlier version restored, the page re-reading
+// with it (D2).
+//
+// The document is a scratch item with real properties — a whole territory, as the model
+// requires of a schema:City (Rockville's row from territories.csv) — written through ldh off camera: once on creation, then twice more with
+// the description changed, so the diff has a row to mark and the restore something to
+// bring back. Versioning on the dataspace is the prerequisite (see FINDINGS #16 for why
+// the restore is pressed from the current version, never from inside a comparison).
+//
+// Writes only to its own scratch document, which it removes at the end.
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs';
+import { resetDocument } from '../lib/fixture.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTop } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity, ldh, certFile, certPasswordFile, certPassword } = opts;
+const password = certPassword ?? (await fs.readFile(certPasswordFile, 'utf8')).trim();
+const auth = ['-c', certFile, '-p', password];
+const run = (args, input = null) => new Promise((res) => {
+ const p = spawn(ldh, args);
+ if (input !== null) { p.stdin.write(input); p.stdin.end(); }
+ let out = '', err = '';
+ p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d));
+ p.on('close', (code) => res({ code, out: out.trim(), err: err.trim().split(password).join('••••') }));
+});
+
+// A fresh slug every run: the history is keyed by the document's URL, so reusing one
+// would show last run's versions on top of this run's three.
+const slug = `overview-versions-${Date.now().toString(36)}`;
+const root = base.endsWith('/') ? base : base + '/';
+const { url } = await resetDocument({ ldh, base, certFile, certPassword, certPasswordFile, container: root, slug, title: 'Rockville' });
+const DESCRIPTIONS = [
+ 'A territory in the Eastern region, covered from the Boston office.',
+ 'A territory in the Eastern region, covered from the Boston office since 1996.',
+ 'A territory in the Eastern region, covered from the Providence office since 1996.',
+];
+const doc = (description) => `@prefix dh: .
+@prefix dct: .
+@prefix sioc: .
+@prefix schema: .
+@prefix foaf: .
+@prefix geo: .
+@prefix xsd: .
+<${url}> a dh:Item ; dct:title "Rockville" ; sioc:has_container <${root}> ; foaf:primaryTopic <${url}#this> .
+<${url}#this> a schema:City ; schema:name "Rockville" ; schema:description ${JSON.stringify(description)} ;
+ schema:identifier "20852" ; schema:containedInPlace <${root}regions/1/#this> ;
+ geo:lat "39.0496"^^xsd:float ; geo:long "-77.1204"^^xsd:float .
+`;
+for (const d of DESCRIPTIONS) {
+ const put = await run(['put', '-t', 'text/turtle', url, ...auth], doc(d));
+ if (put.code !== 0) throw new Error(`fixture: ${put.err.slice(0, 200)}`);
+ await sleep(2500); // commits are asynchronous and chained
+}
+await sleep(4000);
+const READ = `${url}?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#ReadMode')}`;
+
+await runScene({
+ id: 'overview-d-versions', target: READ, warm: READ, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await zoom2x(page);
+ await load(page, READ, '.ldh-pane.is-active .ldh-block', 3000);
+
+ // ── D1 · the History dialog ────────────────────────────────────────────────
+ const link = () => page.locator('a.document-history').first();
+ if (!(await link().isVisible().catch(() => false))) throw new Error('D1: no history link — is versioning enabled for this dataspace?');
+ await cursor.moveTo(...(await centre(link())), { duration: 700 });
+ await marks.beat('D1-start', 'the document; pointer on its History link', await focus(ui(page).locator('.ldh-block').first()));
+ await cursor.click(link());
+ const modal = () => page.locator('.ac-modal:visible').last();
+ await modal().waitFor({ state: 'visible', timeout: 15_000 });
+ await until(page.waitForFunction(() => { const ms = [...document.querySelectorAll('.ac-modal')].filter((m) => m.offsetParent !== null); const m = ms.at(-1); return !!m && m.querySelectorAll('input[type=radio]').length >= 4; }, null, { timeout: 20_000 }), 'the versions in the dialog');
+ await sleep(2500);
+ const rows = () => modal().locator('tr').filter({ has: page.locator('input[type=radio]') });
+ await marks.beat('D1-end', `${await rows().count()} versions, each with its agent`, await focus(modal()));
+ await sleep(800);
+
+ // ── D2 · compare, then restore ─────────────────────────────────────────────
+ // From is the first version WITH the description (the creation above it is the bare
+ // document, against which every row would read as added); To is the latest.
+ await marks.beat('D2-start', 'From and To: the first described version against the latest', await focus(modal()));
+ await cursor.click(rows().nth(2).locator('input[type=radio]').first()); await sleep(600);
+ await cursor.click(rows().first().locator('input[type=radio]').last()); await sleep(600);
+ const compare = modal().locator('button').filter({ hasText: /Compare/ }).last();
+ await cursor.moveTo(...(await centre(compare)), { duration: 500 });
+ await marks.beat('D2-picked', 'pointer on Compare', await focus(modal()));
+ await cursor.click(compare);
+ await until(page.waitForFunction(() => document.querySelectorAll('.ldh-pane.is-active [class*="changed"], .ldh-pane.is-active [class*="added"], .ldh-pane.is-active [class*="removed"]').length > 0, null, { timeout: 30_000 }), 'the diff marks on the document');
+ await easeScrollTop(page, { ms: 1000 }); await sleep(1500);
+ const marked = ui(page).locator('[class*="changed"], [class*="added"], [class*="removed"]');
+ await marks.beat('D2-diff', `${await marked.count()} rows marked: the description changed`, await focus(ui(page).locator('.ldh-block').first()));
+ await sleep(2500);
+
+ // Back to the current version — Restore is only honest from there (FINDINGS #16).
+ const current = ui(page).locator('a').filter({ hasText: /current version/i }).first();
+ if (!(await current.count())) throw new Error('D2: no link back to the current version');
+ await cursor.click(current);
+ await until(page.waitForFunction(() => !/version=/.test(location.search) && !!document.querySelector('.ldh-pane.is-active .ldh-block'), null, { timeout: 30_000 }), 'the current version');
+ await sleep(2000);
+ await marks.beat('D2-current', 'the current version again; pointer to History');
+ await cursor.click(link());
+ await modal().waitFor({ state: 'visible', timeout: 15_000 });
+ await until(page.waitForFunction(() => { const ms = [...document.querySelectorAll('.ac-modal')].filter((m) => m.offsetParent !== null); const m = ms.at(-1); return !!m && m.querySelectorAll('button.btn-restore').length >= 2; }, null, { timeout: 20_000 }), 'the Restore buttons');
+ await sleep(1500);
+ // The second version: the one with the Boston office, before the move.
+ const restore = rows().nth(1).locator('button.btn-restore').first();
+ await cursor.moveTo(...(await centre(restore)), { duration: 600 });
+ await marks.beat('D2-restore', 'pointer on Restore, on the previous version', await focus(modal()));
+ page.once('dialog', (d) => d.accept());
+ await cursor.click(restore);
+ await until(page.waitForFunction(() => ![...document.querySelectorAll('.ac-modal')].some((m) => m.offsetParent !== null), null, { timeout: 30_000 }), 'the dialog closing after the restore');
+ await until(page.waitForFunction((t) => (document.querySelector('.ldh-pane.is-active')?.innerText ?? '').includes(t), DESCRIPTIONS[1], { timeout: 30_000 }), 'the restored description on the page');
+ await sleep(2500);
+ await marks.beat('D2-end', 'restored: the page re-read with the earlier description', await focus(ui(page).locator('.ldh-block').first()));
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
+console.log(' deleted scratch:', (await run(['delete', url, ...auth])).code === 0);
diff --git a/screencast/scenes/overview-e-model.mjs b/screencast/scenes/overview-e-model.mjs
new file mode 100644
index 00000000..a3fd7216
--- /dev/null
+++ b/screencast/scenes/overview-e-model.mjs
@@ -0,0 +1,220 @@
+// Flow E of the overview — the model builds the interface: Create pressed in the header
+// of a property view, the form the class's constructor wrote (E1); Save with the name
+// empty, the constraint's violation on that field (E2); the name filled, Save, and the
+// view the form came from listing the new record (E3); the page following a switch of
+// the reader's colour scheme (E4 — the phone-width half is overview-e-phone.mjs, a take
+// of its own, since the recording keeps its frame when the viewport narrows).
+//
+// Product, not Person: schema:Product carries spin:constraint :MissingName, so an empty
+// name is refused by the model's own rule and the violation lands on the Name field. (A
+// Person form submitted empty is refused on the document's title instead, and the dialog
+// then shows the document form — a platform rule, not the model's.) The view is
+// "Products in this category" on Beverages; the product is removed off camera before the
+// take so the count reads the same every time.
+import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs';
+import { deleteByTitle } from '../lib/fixture.mjs';
+import { editResource, setLiteral, saveForm } from '../lib/editing.mjs';
+import { ui } from '../lib/dom.mjs';
+import { pickByLabel } from '../lib/blocks.mjs';
+import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTo } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const PRODUCT = 'Rooibos Chai';
+const CATEGORY = `${base}/categories/1/`; // Beverages
+const gone = await deleteByTitle({ ldh: opts.ldh, base, certFile: opts.certFile, certPassword: opts.certPassword, certPasswordFile: opts.certPasswordFile, title: PRODUCT });
+if (gone.failed?.length) throw new Error(`reset: could not remove ${gone.failed.length} × ${PRODUCT}`);
+const READ = `${CATEGORY}?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#ReadMode')}`;
+
+await runScene({
+ id: 'overview-e-model', target: READ, warm: READ, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, type, marks }) {
+ await zoom2x(page);
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await load(page, READ, '.ldh-pane.is-active .ldh-block', 3000);
+
+ const view = () => ui(page).locator('.ldh-block').filter({ has: page.locator('.ldh-view-toolbar') }).filter({ hasText: 'Products in this category' }).first();
+ const count = async () => (await view().locator('.count b').first().textContent({ timeout: 4000 })).trim();
+ // The view's Create renders the constructor's form either in a dialog (a Person on a
+ // territory) or inline on the page (a Product on a category); either way it is the
+ // visible form that names the class.
+ const modal = () => page.locator('.ac-modal:visible form, form:visible').filter({ hasText: /Product/ }).last();
+ const groups = () => modal().locator('.ldh-prop-group');
+ const fieldOf = async (re) => {
+ const c = await groups().count();
+ for (let i = 0; i < c; i++) {
+ const g = groups().nth(i); const label = ((await g.textContent()) ?? '').replace(/\s+/g, ' ').trim();
+ if (!new RegExp(re, 'i').test(label)) continue;
+ const input = g.locator('input:not([type=hidden]):visible, textarea:visible').first();
+ if (await input.count()) return { group: g, input };
+ }
+ return null;
+ };
+ const typeInto = async (re, value) => {
+ const f = await fieldOf(re);
+ if (!f) throw new Error(`no ${re} field on the form`);
+ await f.input.scrollIntoViewIfNeeded();
+ await cursor.click(f.input); await f.input.pressSequentially(value, { delay: 35 });
+ return f;
+ };
+ const saveBtn = () => modal().locator('button').filter({ hasText: /Save|Create|check/ }).last();
+
+ // ── E1 · Create from the view's header; the generated form ─────────────────
+ const cbtn = view().locator('button.add-instance').first();
+ await cbtn.waitFor({ state: 'visible', timeout: 15_000 });
+ // The view sits below the fold; its header has to come clear of the sticky header
+ // and the floating Create dock, or the pointer's click lands on one of those.
+ await easeScrollTo(view(), { ms: 1400, margin: 260 }); await sleep(600);
+ const before = await count();
+ await cursor.moveTo(...(await centre(cbtn)), { duration: 700 });
+ await marks.beat('E1-start', `${before} products in Beverages; pointer on the view's Create`, await focus(view()));
+ // a beat on the view before Create: the line explains where the view comes from
+ await sleep(3500);
+ await cursor.click(cbtn);
+ if (!(await modal().waitFor({ state: 'visible', timeout: 10_000 }).then(() => true, () => false))) {
+ console.log(' E1: the pointer click opened no form; clicking the control itself');
+ await cbtn.click();
+ await modal().waitFor({ state: 'visible', timeout: 15_000 });
+ }
+ await until(groups().nth(2).waitFor({ state: 'visible', timeout: 10_000 }), 'the Product form\'s fields');
+ await sleep(1500);
+ const labels = (await groups().allTextContents()).map((t) => t.replace(/\s+/g, ' ').trim().slice(0, 24));
+ await marks.beat('E1-end', `the Product form the constructor wrote: ${JSON.stringify(labels)}`, await focus(modal()));
+ await sleep(600);
+
+ // ── E2 · the name left empty; Save; the violation on the field ─────────────
+ // every other property the model requires of a Product (ns.ttl: identifier, description,
+ // category, provider), so the one violation is the name's
+ await marks.beat('E2-start', 'filling in everything but the name');
+ if (await fieldOf('^Title')) await typeInto('^Title', PRODUCT);
+ await typeInto('^Identifier', String(Date.now()).slice(-4));
+ await typeInto('^Description', 'Red tea');
+ for (const [label, pick] of [['Category', 'Beverages'], ['Provider', 'Exotic Liquids']]) {
+ const input = groups().filter({ hasText: new RegExp(`^\\s*${label}`) }).first().locator('input.resource-combobox').first();
+ await input.scrollIntoViewIfNeeded();
+ const picked = await pickByLabel(page, cursor, type, input, pick);
+ if (!picked.ok) throw new Error(`E2: ${label} — ${picked.why ?? 'not picked'}`);
+ await sleep(500);
+ }
+ await saveBtn().scrollIntoViewIfNeeded();
+ await cursor.moveTo(...(await centre(saveBtn())), { duration: 600 });
+ await marks.beat('E2-save', 'Name still empty; pointer on Save', await focus(modal()));
+ await cursor.click(saveBtn());
+ const violated = () => modal().locator('.ldh-prop-group.is-violation');
+ await until(violated().first().waitFor({ state: 'visible', timeout: 20_000 }), 'a violation on a field');
+ await sleep(1500);
+ const which = (await violated().allTextContents()).map((t) => t.replace(/\s+/g, ' ').trim().slice(0, 30));
+ if (!which.some((w) => /name/i.test(w))) throw new Error(`E2: the violation landed on ${JSON.stringify(which)}, not on Name`);
+ await violated().first().scrollIntoViewIfNeeded();
+ await marks.beat('E2-end', `refused by the model: ${JSON.stringify(which)}`, await focus(modal()));
+ await sleep(1000);
+
+ // ── E3 · the name filled; Save; the view lists the record ──────────────────
+ await marks.beat('E3-start', 'pointer to the Name field');
+ await typeInto('^Name', PRODUCT);
+ await saveBtn().scrollIntoViewIfNeeded();
+ await cursor.moveTo(...(await centre(saveBtn())), { duration: 500 });
+ await marks.beat('E3-filled', 'the name in; pointer on Save', await focus(modal()));
+ await cursor.click(saveBtn());
+ await until(modal().waitFor({ state: 'hidden', timeout: 20_000 }), 'the form closing on Save');
+ await page.waitForLoadState('load').catch(() => {});
+ await sleep(2500);
+ console.log(' after save: url', page.url().replace(base, ''));
+ // Create-from-view can land on the new document rather than on the view it came from
+ // (FINDINGS #14). If it did, the way back is the record's own Category link — on
+ // camera, since that is what a person would click — and the view is read there.
+ if (!page.url().startsWith(CATEGORY)) {
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 20_000 }), 'the new record');
+ await sleep(1500);
+ await marks.beat('E3-record', `saved: the record's own document ${page.url().replace(base, '')}`);
+ const back = ui(page).locator('a[href*="/categories/1/"]').first();
+ if (!(await back.count())) throw new Error('E3: no link back to Beverages on the record');
+ await back.scrollIntoViewIfNeeded();
+ await cursor.moveTo(...(await centre(back)), { duration: 600 });
+ await cursor.click(back);
+ await until(page.waitForFunction((t) => location.href.startsWith(t), CATEGORY, { timeout: 20_000 }), 'Beverages again');
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 20_000 }), 'Beverages, rendered again');
+ }
+ await until(page.waitForFunction(([t, b]) => { const v = [...document.querySelectorAll('.ldh-pane.is-active .ldh-block')].find((e) => e.textContent.includes('Products in this category')); const c = v && v.querySelector('.count b'); return c && c.textContent.trim() !== b; }, ['x', before], { timeout: 20_000 }), 'the products view listing the new record');
+ await sleep(1500);
+ await view().scrollIntoViewIfNeeded();
+ await marks.beat('E3-end', `${await count()} products in Beverages (was ${before}): the view lists the new record`, await focus(view()));
+ await sleep(1200);
+
+ // ── E3b · the record opened and edited: the pencil, a value, Save ──────────
+ const link = view().locator('a').filter({ hasText: PRODUCT }).first();
+ if (!(await link.count())) throw new Error('E3b: the new product is not linked from the view');
+ // Mid-viewport, clear of the sticky header and the Create dock, or the pointer's click
+ // lands on one of those instead of the link.
+ await easeScrollTo(link, { ms: 1000, block: 'center' }); await sleep(600);
+ await cursor.moveTo(...(await centre(link)), { duration: 600 });
+ await marks.beat('E3b-start', 'pointer on the new record', await focus(view()));
+ const catUrl = page.url().split('?')[0];
+ await cursor.click(link);
+ const moved = await page.waitForFunction((b) => location.href.split('?')[0] !== b, catUrl, { timeout: 10_000 }).then(() => true, () => false);
+ if (!moved) { console.log(' E3b: the pointer click did not navigate; clicking the link itself'); await link.click(); }
+ await until(page.waitForFunction((b) => location.href.split('?')[0] !== b, catUrl, { timeout: 20_000 }), 'the record\'s document');
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 20_000 }), 'the record, rendered');
+ await sleep(2000);
+ const ed = await editResource(page, cursor, PRODUCT);
+ if (!ed.ok) throw new Error(`E3b: ${ed.why}`);
+ await marks.beat('E3b-form', 'its form, opened with the pencil', await focus(ed.form));
+ // the description was required at creation ('Red tea'); here it is rewritten
+ const DESCRIPTION = 'A caffeine-free red tea from South Africa, served hot or iced.';
+ const lit = await setLiteral(page, cursor, type, ed.form, 'Description', DESCRIPTION);
+ if (!lit.ok) throw new Error(`E3b: ${lit.why}`);
+ await sleep(600);
+ await marks.beat('E3b-filled', 'the description rewritten', await focus(ed.form));
+ // ── E3c · the form behind the form: the class's constructor ────────────────
+ // From the same form, before it is saved: its Edit constructors opens a dialog listing the
+ // properties a new Product starts with, each a literal of a datatype or a resource of a class.
+ // Looked at and cancelled, so the model is as it was; then the form is saved.
+ const ctorBtn = page.locator('button.btn-edit-constructors:visible').first();
+ await until(ctorBtn.waitFor({ state: 'visible', timeout: 20_000 }), 'Edit constructors, once write access to the constructor is known');
+ await easeScrollTo(ctorBtn, { ms: 1000, block: 'center' }); await sleep(400);
+ await cursor.moveTo(...(await centre(ctorBtn)), { duration: 600 });
+ await marks.beat('E3c-start', 'pointer on Edit constructors', await focus(ed.form));
+ await cursor.click(ctorBtn);
+ const ctorDialog = page.locator('[role=dialog]:visible, .modal:visible, dialog[open]').filter({ hasText: /constructor/i }).last();
+ await until(ctorDialog.waitFor({ state: 'visible', timeout: 20_000 }), 'the constructor dialog');
+ await sleep(2500);
+ const ctorRows = await ctorDialog.locator('button, a').filter({ hasText: /\S/ }).allTextContents();
+ await marks.beat('E3c-dialog', `the Product constructor: ${ctorRows.map((t) => t.replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 12).join(', ')}`, await focus(ctorDialog));
+ await sleep(4000);
+ const ctorCancel = ctorDialog.locator('button').filter({ hasText: /^\s*Cancel\s*$/ }).first();
+ await cursor.moveTo(...(await centre(ctorCancel)), { duration: 600 });
+ await cursor.click(ctorCancel);
+ await ctorDialog.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
+ await sleep(800);
+ if (!(await ed.form.isVisible().catch(() => false))) throw new Error('E3c: the record\'s form closed with the constructor dialog');
+ await marks.beat('E3c-cancelled', 'cancelled: the model unchanged, the record\'s form still open');
+ // ldh.css sizes the page column at min-height: 100vh, which the rig's CSS zoom makes two windows
+ // tall: once the form closes, the page would hold an empty screen where it was. Halved here, just
+ // before the save and not earlier, so every shot before it is framed on the page as it was
+ await page.addStyleTag({ content: '#visible-body { min-height: 50vh !important; }' });
+ const sv = await saveForm(page, cursor, ed.form, { settle: 1500 });
+ if (!sv.ok) throw new Error(`E3c: ${sv.why}`);
+ await until(page.waitForFunction((t) => (document.querySelector('.ldh-pane.is-active')?.innerText ?? '').includes(t), DESCRIPTION, { timeout: 30_000 }), 'the description on the page');
+ // the form left the page scrolled to its foot; the camera eases back up to the record it saved
+ await easeScrollTo(ui(page).locator('.ldh-block').first(), { ms: 1200, margin: 200 });
+ await sleep(2000);
+ await marks.beat('E3c-end', 'saved: the page re-read with the description', await focus(ui(page).locator('.ldh-block').first()));
+ await sleep(600);
+
+ // ── E4 · the reader's colour scheme ────────────────────────────────────────
+ await marks.beat('E4-start', 'dark, as the reader\'s system is set');
+ await page.emulateMedia({ colorScheme: 'light' });
+ await sleep(2500);
+ await marks.beat('E4-light', 'the reader switches to light; the page follows, no reload');
+ await sleep(2000);
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await sleep(2500);
+ await marks.beat('E4-end', 'and back');
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
+
+// The product created on camera goes once the take is in the can.
+console.log(' scratch removed:', JSON.stringify(await deleteByTitle({ ldh: opts.ldh, base, certFile: opts.certFile, certPassword: opts.certPassword, certPasswordFile: opts.certPasswordFile, title: PRODUCT })));
diff --git a/screencast/scenes/overview-e-phone.mjs b/screencast/scenes/overview-e-phone.mjs
new file mode 100644
index 00000000..8d6031f9
--- /dev/null
+++ b/screencast/scenes/overview-e-phone.mjs
@@ -0,0 +1,61 @@
+// The phone-width half of E4, as a take of its own.
+//
+// Not through the harness's video: Playwright records the viewport in CSS pixels, so a
+// 390×844 phone comes out at 390×844 however the device scale is set, and a wider
+// viewport zoomed 2× lays out as a tablet, since media queries read the viewport. The
+// page is laid out as a real 390×844 phone at device scale 2 and captured as a run of
+// 2× screenshots instead, which ffmpeg assembles into the track at 25 fps; the marks
+// sidecar is written the same way the harness writes it, so the cut reads it as any take.
+//
+// Light, to contrast with the dark desktop frame it sits beside. The territories page,
+// the one E4's desktop half ends on, scrolled slowly down through the map.
+//
+// node scenes/overview-e-phone.mjs --base … --cert-file … --cert-password-file …
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { chromium } from 'playwright';
+import { resolve, TRACKS, sleep } from '../lib/harness.mjs';
+
+const ID = 'overview-e-phone';
+const FPS = 25, SECONDS = 18, SCROLL = 900;
+const opts = await resolve('/territories/');
+const { target, identity } = opts;
+const frames = path.join(TRACKS, `.${ID}`);
+await fs.rm(frames, { recursive: true, force: true });
+await fs.mkdir(frames, { recursive: true });
+
+const browser = await chromium.launch();
+const context = await browser.newContext({
+ ignoreHTTPSErrors: true, viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true,
+ colorScheme: 'light', ...(identity ? { clientCertificates: identity } : {}),
+});
+await context.addCookies([{ name: 'LinkedDataHub.first-time-message', value: 'true', domain: new URL(target).hostname, path: '/' }]);
+const page = await context.newPage();
+await page.goto(target, { waitUntil: 'load' });
+await page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 40_000 });
+await sleep(4000);
+
+// The scroll is driven frame by frame, so each screenshot is one step of an ease and the
+// run reads as a glide whatever a screenshot costs.
+const ease = (u) => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2);
+const n = FPS * SECONDS;
+// a slow glide: the line over it runs about seventeen seconds
+const holdIn = FPS * 1.5, glide = FPS * 15;
+const beats = [{ beat: 'E4-phone-start', at: 0, note: 'the territories page at phone width' }];
+for (let i = 0; i < n; i++) {
+ const u = Math.min(1, Math.max(0, (i - holdIn) / glide));
+ await page.evaluate((y) => window.scrollTo(0, y), Math.round(SCROLL * ease(u)));
+ await page.screenshot({ path: path.join(frames, `${String(i).padStart(4, '0')}.png`) });
+}
+beats.push({ beat: 'E4-phone-end', at: (holdIn + glide) / FPS, note: 'scrolled through the map' }, { beat: 'end', at: n / FPS });
+await browser.close();
+
+const track = path.join(TRACKS, `${ID}.webm`);
+await new Promise((res, rej) => {
+ const p = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-framerate', String(FPS), '-i', path.join(frames, '%04d.png'), '-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', '20', '-pix_fmt', 'yuv420p', track], { stdio: 'inherit' });
+ p.on('close', (c) => (c === 0 ? res() : rej(new Error(`ffmpeg exited ${c}`))));
+});
+await fs.writeFile(path.join(TRACKS, `${ID}.marks.json`), JSON.stringify({ scene: ID, track: ID, duration: n / FPS, beats }, null, 2));
+await fs.rm(frames, { recursive: true, force: true });
+console.log(` track ${track}\n length ${n / FPS}s over ${beats.length} beats (${n} frames at ${FPS} fps, 780×1688)`);
diff --git a/screencast/scenes/overview-f-anon.mjs b/screencast/scenes/overview-f-anon.mjs
new file mode 100644
index 00000000..61cd02d6
--- /dev/null
+++ b/screencast/scenes/overview-f-anon.mjs
@@ -0,0 +1,25 @@
+// F3's proof: the dataspace opened with no certificate at all, after make-public — the
+// private window of the script. Run with --anonymous, after overview-f-cli.mjs.
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, load } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const root = base.endsWith('/') ? base : base + '/';
+
+await runScene({
+ id: 'overview-f-anon', target: root, warm: root, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await zoom2x(page);
+ await load(page, root, '.ldh-pane.is-active .ldh-block', 3000);
+ await cursor.moveTo(2600, 1500, { duration: 500 });
+ const signedOut = await page.locator('button, a').filter({ hasText: /Sign up/ }).count();
+ await marks.beat('F3-anon-start', `the dataspace, anonymously (Sign up in the bar: ${signedOut})`, await focus(ui(page).locator('.ldh-block').first()));
+ await sleep(4000);
+ await marks.beat('F3-anon-end', 'readable by anyone');
+ await sleep(500);
+ await marks.beat('end');
+ },
+});
diff --git a/screencast/scenes/overview-f-cli.mjs b/screencast/scenes/overview-f-cli.mjs
new file mode 100644
index 00000000..45a7152d
--- /dev/null
+++ b/screencast/scenes/overview-f-cli.mjs
@@ -0,0 +1,128 @@
+// Flow F of the overview — the CLI: a folder of Turtle pushed into an emptied dataspace
+// (F1, a terminal take), the dataspace rendered in the browser straight after (F2), and
+// the dataspace made public from the command line (F3, a terminal take); the anonymous
+// visit that proves it is overview-f-anon.mjs, a take of its own without a certificate.
+//
+// The dataspace is the documentation site on the dev stack, and the folder is the docs
+// app in this repository — the push that docs/install.sh does. Off camera first: every
+// document in the dataspace is deleted, so what the push brings alive was not there, and
+// the public-read authorization is removed, so make-public has something to do. Both are
+// restored by the takes themselves; the dataspace ends as it began.
+//
+// Two terminal takes are recorded in the middle of the browser take, so the browser
+// shows the dataspace as the commands left it.
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { runScene, resolve, geometryFrom, sleep, until, TRACKS, ROOT } from '../lib/harness.mjs';
+import { record } from '../lib/terminal.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, load, easeScrollTo } from '../lib/supercut.mjs';
+import { scrollThrough } from '../lib/frame.mjs';
+
+const opts = await resolve('/');
+const { base, identity, ldh, certFile, certPasswordFile, certPassword } = opts;
+const password = certPassword ?? (await fs.readFile(certPasswordFile, 'utf8')).trim();
+const root = base.endsWith('/') ? base : base + '/';
+const ADMIN = base.replace('://', '://admin.');
+const APP_DIR = path.resolve(ROOT, '..', 'docs');
+const run = (args) => new Promise((res) => { const p = spawn(ldh, args); let out = '', err = ''; p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d)); p.on('close', (code) => res({ code, out: out.trim(), err: err.trim().split(password).join('••••') })); });
+const auth = ['-c', certFile, '-p', password];
+
+// ── off camera: empty the dataspace, withdraw public access ──────────────────────
+// The endpoint answers the owner even once the dataspace is no longer public, which it is
+// not between the reset and make-public; the rig's anonymous select would be refused.
+const selectAs = (endpoint, query) => new Promise((res, rej) => {
+ const p = spawn('curl', ['-sk', '-m', '60', `${endpoint}/sparql`, '-H', 'Accept: text/csv', '--data-urlencode', `query=${query}`, '--cert-type', 'P12', '--cert', `${certFile}:${password}`]);
+ let out = ''; p.stdout.on('data', (d) => (out += d)); p.on('close', (code) => code ? rej(new Error(`curl exited ${code}`)) : res(out.trim().split('\n').slice(1).map((l) => l.trim().replace(/^"|"$/g, ''))));
+});
+const docs = (await selectAs(base, 'SELECT DISTINCT ?g { GRAPH ?g { ?s ?p ?o } }')).filter((g) => g.startsWith(root) && g !== root && !g.startsWith(`${root}uploads/`));
+let gone = 0;
+for (const g of docs) if ((await run(['delete', g, ...auth])).code === 0) gone++;
+console.log(` reset: ${gone}/${docs.length} documents deleted`);
+// `ldh admin make-public` PATCHes the authorization the platform seeds for every
+// dataspace at acl/authorizations/public/ — Read for anyone, but with no accessTo until
+// the command adds the document classes. The reset puts that document back to its seeded
+// state (platform/datasets/admin.trig), so the command on camera has its work to do.
+const PUBLIC_AUTH = `${ADMIN}/acl/authorizations/public/`;
+const seeded = `@prefix dh: .
+@prefix sioc: .
+@prefix dct: .
+@prefix foaf: .
+@prefix rdfs: .
+@prefix acl: .
+<${PUBLIC_AUTH}> a dh:Item ; sioc:has_container <${ADMIN}/acl/authorizations/> ; dct:title "Public access" ; foaf:primaryTopic <${PUBLIC_AUTH}#this> .
+<${PUBLIC_AUTH}#this> a acl:Authorization ; rdfs:label "Public access" ; rdfs:comment "Allows non-authenticated access" ; acl:mode acl:Read ; acl:agentClass foaf:Agent, acl:AuthenticatedAgent .
+`;
+const runIn = (args, input) => new Promise((res) => { const p = spawn(ldh, args); p.stdin.write(input); p.stdin.end(); let out = '', err = ''; p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d)); p.on('close', (code) => res({ code, out: out.trim(), err: err.trim().split(password).join('••••') })); });
+const reseeded = await runIn(['put', '-t', 'text/turtle', PUBLIC_AUTH, ...auth], seeded);
+console.log(` reset: ${PUBLIC_AUTH.replace(ADMIN, '')} back to its seeded state: ${reseeded.code === 0 ? 'ok' : reseeded.err.slice(0, 160)}`);
+await sleep(2000);
+// Node's fetch refuses the stack's self-signed certificate; curl -k is the honest probe.
+// Past the cache, which would answer 200 from a page cached before the authorization went.
+const status = (url) => new Promise((res) => { const p = spawn('curl', ['-sk', '-o', '/dev/null', '-m', '20', '-H', 'Cache-Control: no-cache', '-w', '%{http_code}', `${url}?nc=${Date.now()}`]); let out = ''; p.stdout.on('data', (d) => (out += d)); p.on('close', () => res(out.trim())); });
+const anon = await status(root);
+console.log(` reset: anonymous GET ${root} → ${anon}`);
+if (anon !== '403') throw new Error(`reset: the dataspace is still readable anonymously (${anon}); nothing for make-public to do`);
+
+// F3 creates an Editors group and an authorization for it: last take's go first, so the commands
+// on camera create rather than collide. The group's member is the owner, whose WebID is the root
+// document's creator
+const ADMIN_ROOT = `${ADMIN}/`;
+for (const d of ['acl/groups/editors/', 'acl/authorizations/editors-user-guide/']) await run(['delete', `${ADMIN_ROOT}${d}`, ...auth]);
+const WEBID = ((await run(['get', '--accept', 'text/turtle', root, ...auth])).out.match(/<(https:\/\/[^>]+\/acl\/agents\/[^>]+#this)>/) ?? [])[1];
+if (!WEBID) throw new Error('reset: no owner WebID on the root document');
+console.log(` reset: Editors group and authorization removed; member ${WEBID}`);
+
+const env = { LDH_BASE: root, LDH_ADMIN_BASE: ADMIN_ROOT, WEBID, LDH_CERT_FILE: path.resolve(certFile), LDH_CERT_PASSWORD: password, LDH_CLIP_DIR: APP_DIR };
+
+await runScene({
+ id: 'overview-f-cli', target: root, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await zoom2x(page);
+
+ // ── F1 · the push, in the terminal ─────────────────────────────────────────
+ await marks.beat('F1-terminal-start', 'the push take begins');
+ const push = await record(path.join(ROOT, 'tapes', 'overview-f-push.tape'), { output: path.join(TRACKS, 'overview-f-push-terminal.mp4'), env });
+ if (!push.ok) throw new Error(`F1: ${push.why}`);
+ await marks.beat('F1-terminal-end', `the push take: ${push.seconds}s`);
+ const after = (await selectAs(base, 'SELECT (COUNT(DISTINCT ?g) AS ?n) { GRAPH ?g { ?s ?p ?o } }').catch(() => ['?']))[0];
+ console.log(` after the push: ${after} graphs`);
+
+ // ── F2 · the dataspace, live ───────────────────────────────────────────────
+ await load(page, root, '.ldh-pane.is-active .ldh-block', 3000);
+ await cursor.moveTo(2600, 1500, { duration: 500 });
+ await marks.beat('F2-start', 'the pushed dataspace: its home page');
+ // The page scrolls inside its pane, not the document, so the glide is to the last block, at the
+ // pace of the line over it (about nine seconds)
+ const blocks = ui(page).locator('.ldh-block');
+ await sleep(1200);
+ await easeScrollTo(blocks.last(), { ms: 8000, block: 'end' });
+ await sleep(800);
+ await marks.beat('F2-end', 'scrolled through the home page', await focus(blocks.last()));
+ await sleep(800);
+
+ // ── F3 · make-public, in the terminal ──────────────────────────────────────
+ await marks.beat('F3-terminal-start', 'the make-public take begins');
+ const pub = await record(path.join(ROOT, 'tapes', 'overview-f-public.tape'), { output: path.join(TRACKS, 'overview-f-public-terminal.mp4'), env });
+ if (!pub.ok) throw new Error(`F3: ${pub.why}`);
+ await marks.beat('F3-terminal-end', `the make-public take: ${pub.seconds}s`);
+ await sleep(2000);
+ const anonAfter = await status(root);
+ console.log(` after make-public: anonymous GET ${root} → ${anonAfter}`);
+ if (anonAfter !== '200') throw new Error(`F3: the dataspace is not public after make-public (${anonAfter})`);
+
+ for (const d of ['acl/groups/editors/', 'acl/authorizations/editors-user-guide/']) {
+ const got = await run(['get', '--head', '--accept', 'text/turtle', `${ADMIN_ROOT}${d}`, ...auth]);
+ if (got.code !== 0) throw new Error(`F3: ${d} was not created`);
+ }
+
+ // ── F3b · the same document as Turtle ──────────────────────────────────────
+ await marks.beat('F3b-terminal-start', 'the ldh get take begins');
+ const neg = await record(path.join(ROOT, 'tapes', 'overview-f-get.tape'), { output: path.join(TRACKS, 'overview-f-get-terminal.mp4'), env });
+ if (!neg.ok) throw new Error(`F3b: ${neg.why}`);
+ await marks.beat('F3b-terminal-end', `the ldh get take: ${neg.seconds}s`);
+ await marks.beat('end');
+ },
+});
diff --git a/screencast/scenes/overview-f-login.mjs b/screencast/scenes/overview-f-login.mjs
new file mode 100644
index 00000000..83572192
--- /dev/null
+++ b/screencast/scenes/overview-f-login.mjs
@@ -0,0 +1,53 @@
+// F4: the ways in, signed out — the Login menu's Google and ORCID, then Sign up, which
+// issues a WebID certificate. Run with --anonymous on a stack configured with the Google
+// and ORCID clients (the menu lists only what is configured). The two halves are cut in
+// the narration's order: the sign-up form first, then the menu.
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+import { chrome } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, load } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const root = base.endsWith('/') ? base : base + '/';
+
+await runScene({
+ id: 'overview-f-login', target: root, warm: root, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await zoom2x(page);
+ await load(page, root, '.ldh-pane.is-active .ldh-block', 3000);
+ const bar = chrome(page);
+ const login = bar.locator('button.drop-toggle').filter({ hasText: /Login/ }).first();
+ const google = bar.locator('a.ac-menu-item').filter({ hasText: 'Login with Google' }).first();
+ const orcid = bar.locator('a.ac-menu-item').filter({ hasText: 'Login with ORCID' }).first();
+ const signUp = bar.locator('a').filter({ hasText: /^\s*Sign up\s*$/ }).first();
+
+ await cursor.moveTo(2200, 700, { duration: 500 });
+ await marks.beat('F4-menu-start', 'signed out: Login and Sign up in the bar', await focus(login, signUp));
+ await cursor.click(login);
+ await google.waitFor({ state: 'visible', timeout: 5000 });
+ await marks.beat('F4-menu-open', 'Login with Google, Login with ORCID', await focus(login, google, orcid));
+ await cursor.moveOver(google, { duration: 500 });
+ await sleep(1200);
+ await cursor.moveOver(orcid, { duration: 400 });
+ await sleep(1200);
+ await marks.beat('F4-menu-end', 'both providers shown');
+ await page.keyboard.press('Escape');
+ await sleep(300);
+
+ // the sign-up form lives on the administration host, which the first-time cookie does not cover
+ const href = await signUp.getAttribute('href');
+ const { hostname } = new URL(href);
+ await page.context().addCookies([{ name: 'LinkedDataHub.first-time-message', value: 'true', domain: hostname, path: '/' }]);
+ await cursor.click(signUp);
+ await page.waitForURL((u) => u.hostname === hostname, { timeout: 30_000 });
+ await page.waitForSelector('form input[type=email]', { timeout: 40_000 });
+ await sleep(1500);
+ await marks.beat('F4-webid-start', 'Sign up: the WebID profile and its certificate', await focus(page.locator('form').first()));
+ await cursor.moveTo(1400, 900, { duration: 900 });
+ await sleep(3500);
+ await marks.beat('F4-webid-end', 'the WebID sign-up form');
+ await sleep(500);
+ await marks.beat('end');
+ },
+});
diff --git a/screencast/scenes/overview-g-packages.mjs b/screencast/scenes/overview-g-packages.mjs
new file mode 100644
index 00000000..8751e41b
--- /dev/null
+++ b/screencast/scenes/overview-g-packages.mjs
@@ -0,0 +1,123 @@
+// Flow G of the overview — packages, one triple: a concept in the thesaurus rendered as a
+// plain resource, no tree (G1); the package added from the command line (G2, a terminal
+// take this scene records in the middle of itself, so the add on camera is the add the
+// browser then shows); the same concept with the concept tree in the content column and
+// its broader and narrower views (G3); the package's ontology, a document of the
+// dataspace's own under admin/ontologies/, opened for editing (G4).
+//
+// Off camera first: `ldh packages remove`, so the "before" is real. The take leaves the
+// package installed, which is the dataspace's normal state.
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { runScene, resolve, geometryFrom, sleep, until, TRACKS } from '../lib/harness.mjs';
+import { record } from '../lib/terminal.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTo } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity, ldh, certFile, certPasswordFile, certPassword } = opts;
+const password = certPassword ?? (await fs.readFile(certPasswordFile, 'utf8')).trim();
+const PACKAGE = 'https://packages.linkeddatahub.com/editor/taxonomy/#this';
+const CONCEPT = `${base}/concepts/concept7367/`; // Denmark
+const ADMIN = base.replace('://', '://admin.');
+const ONTOLOGY_DOC = `${ADMIN}/ontologies/editor-taxonomy/`;
+
+const run = (args) => new Promise((res) => { const p = spawn(ldh, args); let out = '', err = ''; p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d)); p.on('close', (code) => res({ code, out: out.trim(), err: err.trim().split(password).join('••••') })); });
+const auth = [`${base}/`, '-c', certFile, '-p', password];
+const removed = await run(['packages', 'remove', ...auth, '--package', PACKAGE]);
+if (removed.code !== 0) throw new Error(`reset: could not remove the package: ${removed.err.slice(0, 200)}`);
+console.log(' reset: package removed');
+await sleep(3000);
+const READ = `${CONCEPT}?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#ReadMode')}`;
+
+// G4 is on the admin origin, which needs the owner's certificate too: the identity the
+// harness resolves is bound to the end-user origin, so the admin origin gets a copy.
+const identities = identity ? [...identity, { ...identity[0], origin: ADMIN }] : null;
+
+await runScene({
+ id: 'overview-g-packages', target: READ, warm: READ, identity: identities,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await zoom2x(page);
+ const aside = () => ui(page).locator('.ldh-content-aside').first();
+
+ // ── G1 · before: the concept, no tree ──────────────────────────────────────
+ await load(page, READ, '.ldh-pane.is-active .ldh-block', 3000);
+ if (await aside().count()) throw new Error('G1: the concept tree is on the page although the package was removed');
+ await cursor.moveTo(2600, 1500, { duration: 600 });
+ await marks.beat('G1-start', 'the concept as a plain resource: labels, broader, no tree');
+ await sleep(4000);
+ await marks.beat('G1-end', 'still the plain resource');
+
+ // ── G2 · the terminal: list, add, list ─────────────────────────────────────
+ // Recorded here, between the browser's before and after, so the add the viewer sees
+ // typed is the add that changes the page. The browser video runs on meanwhile; the
+ // cut drops that stretch.
+ await marks.beat('G2-terminal-start', 'the terminal take begins');
+ const tape = await record(path.resolve(TRACKS, '..', 'tapes', 'overview-g-packages.tape'), {
+ output: path.join(TRACKS, 'overview-g-packages-terminal.mp4'),
+ env: { LDH_BASE: `${base}/`, LDH_CERT_FILE: path.resolve(certFile), LDH_CERT_PASSWORD: password },
+ });
+ if (!tape.ok) throw new Error(`G2: ${tape.why}`);
+ await marks.beat('G2-terminal-end', `the terminal take: ${tape.seconds}s, ${tape.output}`);
+ const listed = await run(['packages', 'list', ...auth]);
+ if (!/installed\s+\S*editor\/taxonomy/.test(listed.out)) throw new Error(`G2: the package is not installed after the tape: ${listed.out.slice(0, 200)}`);
+
+ // ── G3 · after: the same concept, with the tree ────────────────────────────
+ await marks.beat('G3-start', 'the same concept, reloaded');
+ await page.reload({ waitUntil: 'load' });
+ await until(aside().waitFor({ state: 'visible', timeout: 60_000 }), 'the concept tree in the content column');
+ await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 30_000 }), 'the concept, rendered');
+ await until(aside().locator('li.is-active').first().waitFor({ state: 'attached', timeout: 30_000 }), 'the concept marked in the tree');
+ await sleep(1500);
+ // The tree centres the concept it reveals (skos.xsl ldh:conceptree-activate), but under the
+ // rig's CSS zoom its sums are off: getBoundingClientRect() is zoomed while scrollTop is not,
+ // and the tree's viewport-high cap doubles, so the card runs past the frame and the concept
+ // is scrolled out of sight. A reader's browser zoom scales both alike. The camera puts the
+ // first marked row in the middle of the tree's on-screen part, eased, in the tree's own units.
+ await page.evaluate((ms) => new Promise((done) => {
+ const tree = document.querySelector('.ldh-content-aside .ldh-tree') ?? document.querySelector('.ldh-content-aside');
+ const row = tree.querySelector('li.is-active > div');
+ const tr = tree.getBoundingClientRect(), rr = row.getBoundingClientRect();
+ const z = tr.height / tree.offsetHeight;
+ const shown = (Math.min(tr.bottom, window.innerHeight) - tr.top) / z;
+ const to = tree.scrollTop + (rr.top - tr.top) / z - (shown - rr.height / z) / 2;
+ const from = tree.scrollTop, t0 = performance.now();
+ const step = (t) => { const k = Math.min(1, (t - t0) / ms), e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2; tree.scrollTop = from + (to - from) * e; if (k < 1) requestAnimationFrame(step); else done(); };
+ requestAnimationFrame(step);
+ }), 1400);
+ await sleep(1500);
+ await marks.beat('G3-tree', 'the concept tree, opened down to the concept', await focus(aside().locator('li.is-active').first()));
+ const views = ui(page).locator('.ldh-block').filter({ has: page.locator('.ldh-view-toolbar') });
+ const names = (await views.locator('.ldh-block-head').allTextContents().catch(() => [])).map((t) => t.replace(/\s+/g, ' ').trim().slice(0, 30));
+ if (await views.count()) { await easeScrollTo(views.first(), { ms: 1600, margin: 260 }); await sleep(1500); }
+ await marks.beat('G3-end', `the package's views: ${JSON.stringify(names)}`, await focus(views.first()));
+ await sleep(1000);
+
+ // ── G4 · the ontology, a document of the dataspace's own ───────────────────
+ // The first-time dialog is dismissed by a cookie the harness seeds for the end-user
+ // host; the admin host is another host, so it gets the same cookie here.
+ await page.context().addCookies([{ name: 'LinkedDataHub.first-time-message', value: 'true', domain: new URL(ADMIN).hostname, path: '/' }]);
+ await marks.beat('G4-start', 'to the admin dataspace: the ontology the package brought');
+ await load(page, ONTOLOGY_DOC, '.ldh-pane.is-active .ldh-block', 3000);
+ await sleep(1500);
+ const first = ui(page).locator('.ldh-block').first();
+ await marks.beat('G4-doc', `the ontology document: ${page.url().replace(base, '')}`, await focus(first));
+ // The edit pencil on a resource description appears under acl:Write: here it is.
+ const pencil = first.locator('button.ac-iconbtn').filter({ hasText: 'edit' }).first();
+ if (await pencil.count()) {
+ await first.hover(); await sleep(600);
+ await cursor.moveTo(...(await centre(pencil)), { duration: 700 });
+ await cursor.click(pencil);
+ await until(page.locator('form:visible').first().waitFor({ state: 'visible', timeout: 15_000 }), 'the ontology\'s edit form');
+ await sleep(2500);
+ await marks.beat('G4-end', 'the ontology open for editing, like any resource of the dataspace', await focus(page.locator('form:visible').first()));
+ await page.keyboard.press('Escape');
+ } else {
+ await marks.beat('G4-end', 'the ontology document (no edit control found)', await focus(first));
+ }
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
diff --git a/screencast/scenes/overview-h-agent.mjs b/screencast/scenes/overview-h-agent.mjs
new file mode 100644
index 00000000..073f9690
--- /dev/null
+++ b/screencast/scenes/overview-h-agent.mjs
@@ -0,0 +1,134 @@
+// Flow H of the overview, its second half — an agent over MCP. A terminal take (H5–H6,
+// tapes/overview-h-agent.tape): Claude Code registers Web-Algebra with the command the agents
+// docs page gives, `/mcp` lists it connected, and the docs page's prompt — a container of new offers,
+// a 20% discount on three Beverages products as schema:Offers of them, and a chart of the offer
+// prices — is answered in tool calls. Then the browser (H7): the offers container the agent
+// wrote, its items, and the chart on its page.
+//
+// Off camera, before the take: the previous take's offers documents are removed, and the
+// Web-Algebra checkout is built to a wheel that the tape's `uv run --with web-algebra` resolves
+// to (through UV_FIND_LINKS), so the agent runs the operations as they stand rather than the
+// last PyPI release. The terminal runs in the LinkedDataHub deployment's directory, where the
+// docs command's ssl/ and secrets/ paths resolve.
+//
+// node scenes/overview-h-agent.mjs --base … --cert-file … --cert-password-file …
+// [--deployment ../../linkeddatahub.com] [--web-algebra ../../Web-Algebra] [--browser-only]
+//
+// The agent's documents are removed after a take only when H7 was filmed; --browser-only films
+// H7 again from the documents a failed one left, without another terminal take.
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { runScene, resolve, geometryFrom, sleep, until, TRACKS, ROOT } from '../lib/harness.mjs';
+import { record } from '../lib/terminal.mjs';
+import { removeAll } from '../lib/fixture.mjs';
+import { select } from '../lib/sparql.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, load, easeScrollTo } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const argv = process.argv.slice(2);
+const value = (name, fallback) => { const i = argv.indexOf(`--${name}`); return i === -1 ? fallback : argv[i + 1]; };
+const DEPLOYMENT = path.resolve(value('deployment', path.join(ROOT, '..', '..', 'linkeddatahub.com')));
+const WEB_ALGEBRA = path.resolve(value('web-algebra', path.join(ROOT, '..', '..', 'Web-Algebra')));
+const OFFERS = `${base}/offers/`;
+const BROWSER_ONLY = argv.includes('--browser-only');
+
+const run = (cmd, args, options = {}) => new Promise((res) => {
+ const p = spawn(cmd, args, options); let out = '', err = '';
+ p.stdout?.on('data', (d) => (out += d)); p.stderr?.on('data', (d) => (err += d));
+ p.on('error', (e) => res({ code: -1, out, err: e.message }));
+ p.on('close', (code) => res({ code, out: out.trim(), err: err.trim() }));
+});
+
+// ── off camera ─────────────────────────────────────────────────────────────────
+const underOffers = async () => (await select(base, `SELECT DISTINCT ?doc WHERE { GRAPH ?doc { ?s ?p ?o } FILTER(STRSTARTS(STR(?doc), ${JSON.stringify(OFFERS)})) }`)).map((r) => r.doc);
+if (!BROWSER_ONLY) {
+ const previous = (await underOffers()).sort((a, b) => b.length - a.length); // children before the container
+ if (previous.length) {
+ const gone = await removeAll({ ...opts, urls: previous });
+ const failed = gone.filter(([, ok]) => !ok).map(([u]) => u);
+ if (failed.length) throw new Error(`reset: could not remove ${failed.join(', ')}`);
+ }
+ console.log(` reset: ${previous.length} offers documents removed`);
+
+ const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'ldh-overview-h-'));
+ const wheels = path.join(scratch, 'wheels');
+ const built = await run('uv', ['build', '--wheel', '-o', wheels], { cwd: WEB_ALGEBRA });
+ if (built.code !== 0) throw new Error(`could not build Web-Algebra: ${built.err.slice(-400)}`);
+ const settings = path.join(scratch, 'settings.json');
+ await fs.writeFile(settings, JSON.stringify({
+ permissions: { allow: ['mcp__web-algebra'], deny: ['Bash', 'Write', 'Edit'] },
+ enabledPlugins: { 'figma@claude-plugins-official': false },
+ }));
+ console.log(` built: ${(await fs.readdir(wheels)).join(', ')}`);
+
+ // ── H5–H6 · the terminal ───────────────────────────────────────────────────────
+ // The take's Claude Code is a session of its own, not a child of whatever runs this scene: an
+ // inherited CLAUDE* marker puts a warning in its footer, as an available update puts a
+ // notice there
+ for (const k of Object.keys(process.env)) if (k.startsWith('CLAUDE')) delete process.env[k];
+ process.env.DISABLE_AUTOUPDATER = '1';
+ const tape = await record(path.join(ROOT, 'tapes', 'overview-h-agent.tape'), {
+ output: path.join(TRACKS, 'overview-h-agent-terminal.mp4'),
+ env: { TAKE_DIR: DEPLOYMENT, TAKE_WHEELS: wheels, TAKE_SETTINGS: settings, TAKE_EXCLUDE_NEWER: '2026-10-05T00:00:00Z' },
+ });
+ await fs.rm(scratch, { recursive: true, force: true });
+ if (!tape.ok) throw new Error(`H5: ${tape.why}`);
+ console.log(` terminal: ${tape.seconds}s → ${tape.output}`);
+ const written = await underOffers();
+ // the offers may be items of their own or share one: a container and one item at least
+ if (written.length < 2) throw new Error(`H6: the agent wrote ${written.length} documents under offers/, not a container and its offers`);
+ console.log(` written: ${written.map((u) => u.replace(base, '')).join(' ')}`);
+}
+
+// ── H7 · the browser ──────────────────────────────────────────────────────────
+// The agent puts the chart where it sees fit: on the container, or on the item that holds the
+// offers. The take opens on the container either way and, for an item's chart, follows its link.
+const charted = await select(base, `PREFIX ldh:
+PREFIX dct:
+SELECT ?doc ?title WHERE { GRAPH ?doc { ?chart a ldh:ResultSetChart . ?doc dct:title ?title } FILTER(STRSTARTS(STR(?doc), ${JSON.stringify(OFFERS)})) } LIMIT 1`);
+if (!charted.length) throw new Error('H7: no chart under offers/');
+const { doc: chartDoc, title: chartDocTitle } = charted[0];
+console.log(` chart on: ${chartDoc.replace(base, '')}`);
+const CONTENT = `${OFFERS}?mode=${encodeURIComponent('https://w3id.org/atomgraph/linkeddatahub#ContentMode')}`;
+const shot = await runScene({
+ id: 'overview-h-agent', target: CONTENT, warm: CONTENT, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, marks }) {
+ await zoom2x(page);
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await load(page, CONTENT, '.ldh-pane.is-active .ldh-block', 3000);
+ const content = () => ui(page).locator('.content-body').filter({ has: page.locator('> .ldh-create-dock') }).first();
+ const blocks = () => content().locator('> .ldh-block-row');
+ await until(blocks().first().locator('a').first().waitFor({ state: 'visible', timeout: 30_000 }), 'the offers container\'s children');
+ await sleep(1500);
+ await marks.beat('H7-start', 'the offers container the agent wrote', await focus(blocks().first()));
+ await sleep(3000);
+ if (chartDoc !== OFFERS) {
+ const link = blocks().first().locator('a').filter({ hasText: chartDocTitle }).first();
+ await cursor.click(link);
+ await page.waitForURL((u) => u.href.startsWith(chartDoc), { timeout: 30_000 });
+ }
+ const chart = blocks().filter({ has: page.locator('svg') }).last();
+ await until(chart.locator('svg').first().waitFor({ state: 'visible', timeout: 60_000 }), 'the agent\'s chart');
+ await sleep(1500);
+ await easeScrollTo(chart, { ms: 1600, margin: 200 }); await sleep(3000);
+ const title = (await chart.locator('.ldh-block-head .ttl').first().textContent().catch(() => '')).trim();
+ await marks.beat('H7-chart', `the chart: ${title}`, await focus(chart));
+ await sleep(1500);
+ await cursor.moveTo(1440, 1500, { duration: 600 });
+ await marks.beat('H7-end', 'editable by hand, like any document');
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
+
+// The offers documents go once H7 is filmed, so the other takes' document tree shows the demo's
+// own documents only; a failed H7 leaves them for --browser-only.
+if (shot.failure) process.exit(1);
+const left = (await underOffers()).sort((a, b) => b.length - a.length);
+await removeAll({ ...opts, urls: left });
+console.log(` removed: ${left.length} offers documents`);
diff --git a/screencast/scenes/overview-h-assistant.mjs b/screencast/scenes/overview-h-assistant.mjs
new file mode 100644
index 00000000..b2687f49
--- /dev/null
+++ b/screencast/scenes/overview-h-assistant.mjs
@@ -0,0 +1,143 @@
+// Flow H of the overview, its first half — the assistant, on a scratch Northwind document in
+// Content mode: the create bar's Assistant button starts a chat block, a question is typed and,
+// since its plan only reads, runs at once (H1); the answer in words and the rows as a bar chart,
+// the trace unfolded (H2); a follow-up that writes waits for Execute with its operations listed,
+// and once executed the chart is a block of the page (H3); the page reloaded, the conversation
+// still there as a block of the document (H4).
+//
+// A live model answers, so the plan and the answer's wording differ from take to take: the beats
+// assert the card's states - ran, answered, drawn, waiting, written - and never its text. The
+// scratch document is reset off camera. The waits for the model (10-30 s for each plan) are kept
+// and played fast in the cut: H1-asked and H3-asked mark where each begins, H1-running and H3-plan
+// where it ends.
+import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs';
+import { resetDocument, removeAll } from '../lib/fixture.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTo } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const QUESTION = 'Chart the five customers who ordered the most';
+const FOLLOW_UP = 'Add the chart to this page';
+const doc = await resetDocument({ ...opts, container: `${base}/`, slug: 'overview-assistant', title: 'Our best customers' });
+console.log(` reset: ${doc.url}`);
+const CONTENT = `${doc.url}?mode=${encodeURIComponent('https://w3id.org/atomgraph/linkeddatahub#ContentMode')}`;
+const MODEL = 240_000;
+
+await runScene({
+ id: 'overview-h-assistant', target: CONTENT, warm: CONTENT, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor, type, marks }) {
+ await zoom2x(page);
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await load(page, CONTENT, '.ldh-pane.is-active .ldh-create-dock .ldh-chat-open', 3000);
+
+ const content = ui(page).locator('.content-body').filter({ has: page.locator('> .ldh-create-dock') }).first();
+ const assistant = content.locator('> .ldh-create-dock .ldh-chat-open');
+ let chatUri = null;
+ const chat = () => page.locator(`.ldh-chat[data-chat="${chatUri}"]`);
+ const composer = () => chat().locator('> form.ldh-chat-composer textarea');
+ const card = () => chat().locator('.ldh-chat-log > .ldh-chat-plan').last();
+ // The chat scrolls its newest card into view ('nearest') on every update (chat.xsl
+ // ldh:chat-scroll), so a card growing under the question pushes the question off the top.
+ // The camera is the scene's: those calls are held while it films, and after Enter the page
+ // eases to the question, which stays at the top while the card grows beneath it.
+ const holdChatScroll = () => page.evaluate(() => {
+ if (Element.prototype.__siv) return;
+ Element.prototype.__siv = Element.prototype.scrollIntoView;
+ Element.prototype.scrollIntoView = function (o) { if (this.closest && this.closest('.ldh-chat')) return; return this.__siv(o); };
+ });
+ // the beat after Enter is where the model's wait begins; the cut plays the wait fast
+ const ask = async (text, beat) => {
+ await holdChatScroll();
+ await cursor.moveTo(...(await centre(composer())), { duration: 600 });
+ await type(composer(), text);
+ await sleep(400);
+ await page.keyboard.press('Enter');
+ const turn = chat().locator('.ldh-chat-turn').filter({ hasText: text }).last();
+ await until(turn.waitFor({ state: 'visible', timeout: 10_000 }), 'the question in the log');
+ await easeScrollTo(turn, { ms: 1000, margin: 220 });
+ await sleep(400);
+ await marks.beat(beat, `asked: ${text}`, await focus(chat()));
+ };
+
+ // ── H1 · ask: a read runs at once ──────────────────────────────────────────
+ await cursor.moveTo(...(await centre(assistant)), { duration: 700 });
+ await marks.beat('H1-start', 'an empty page; pointer on the create bar\'s Assistant', await focus(content));
+ await cursor.click(assistant);
+ await until(content.locator('> .ldh-chat-ephemeral .ldh-chat').waitFor({ state: 'visible', timeout: 15_000 }), 'the new chat block');
+ chatUri = await content.locator('> .ldh-chat-ephemeral .ldh-chat').getAttribute('data-chat');
+ await sleep(600);
+ await ask(QUESTION, 'H1-asked');
+ await until(card().locator('.ldh-chat-step').first().waitFor({ state: 'visible', timeout: MODEL }), 'the plan\'s steps');
+ await sleep(500);
+ await marks.beat('H1-running', 'the plan arrived and runs: its steps report', await focus(card()));
+ await until(card().locator('.ldh-chat-answer').filter({ hasText: /\S/ }).waitFor({ timeout: MODEL }), 'the answer');
+ if (await card().locator('.ldh-chat-step.is-failed').count()) throw new Error('H1: a step failed');
+ const chart = card().locator('.ldh-chat-result-block[data-mode$="#ChartMode"] svg').first();
+ await until(chart.waitFor({ state: 'visible', timeout: 30_000 }), 'the answer\'s chart');
+ await sleep(1200);
+ await marks.beat('H1-end', 'answered', await focus(card()));
+
+ // ── H2 · the answer in words, the chart, the trace ─────────────────────────
+ const answer = (await card().locator('.ldh-chat-answer').textContent()).trim();
+ await easeScrollTo(card(), { ms: 1200, margin: 200 }); await sleep(500);
+ await marks.beat('H2-start', `the answer: ${answer.slice(0, 120)}`, await focus(card().locator('.ldh-chat-answer')));
+ await sleep(2500);
+ await easeScrollTo(chart, { ms: 1200, block: 'center' }); await sleep(2500);
+ await marks.beat('H2-chart', 'the rows drawn as a bar chart', await focus(card().locator('.ldh-chat-result-block').first()));
+ const trace = card().locator('details.ldh-chat-trace > summary');
+ await easeScrollTo(trace, { ms: 1000, block: 'center' }); await sleep(400);
+ await cursor.moveTo(...(await centre(trace)), { duration: 600 });
+ await cursor.click(trace);
+ await sleep(2500);
+ await marks.beat('H2-end', `the trace: ${await card().locator('.ldh-chat-step').count()} steps`, await focus(card().locator('details.ldh-chat-trace')));
+ await cursor.click(trace); // folded again, so the card is short when the follow-up lands
+ await sleep(600);
+
+ // ── H3 · a write waits for Execute; the chart becomes a block ──────────────
+ await easeScrollTo(composer(), { ms: 1200, block: 'center' }); await sleep(400);
+ await marks.beat('H3-start', 'the follow-up');
+ await ask(FOLLOW_UP, 'H3-asked');
+ const execute = () => card().locator('.ldh-chat-execute');
+ await until(execute().waitFor({ state: 'visible', timeout: MODEL }), 'the write plan, waiting for Execute');
+ const ops = await card().locator('.ldh-chat-step .op').allTextContents();
+ if (!ops.some((o) => /AddObjectBlock/.test(o))) throw new Error(`H3: the plan does not place the chart: ${ops.join(' ')}`);
+ // the follow-up stays in view above the plan it got
+ await easeScrollTo(chat().locator('.ldh-chat-turn').filter({ hasText: FOLLOW_UP }).last(), { ms: 1200, margin: 220 }); await sleep(1500);
+ await marks.beat('H3-plan', `waiting for Execute: ${ops.map((o) => o.trim()).join(', ')}`, await focus(card()));
+ await sleep(2500);
+ await cursor.moveTo(...(await centre(execute())), { duration: 700 });
+ await sleep(500);
+ await marks.beat('H3-execute', 'pointer on Execute', await focus(card()));
+ await cursor.click(execute());
+ await until(card().locator('.ldh-chat-answer').filter({ hasText: /\S/ }).waitFor({ timeout: MODEL }), 'the write\'s answer');
+ if (await card().locator('.ldh-chat-step.is-failed').count()) throw new Error('H3: a step failed');
+ // the page catches up with the write: a block besides the chat, drawing a chart
+ const placed = content.locator('> .ldh-block-row').filter({ hasNot: page.locator('.ldh-chat') }).last();
+ await until(placed.locator('svg').first().waitFor({ state: 'visible', timeout: 90_000 }), 'the chart block on the page');
+ await marks.beat('H3-written', 'executed: the page caught up', await focus(card()));
+ await easeScrollTo(placed, { ms: 1600, margin: 200 }); await sleep(2500);
+ const placedTitle = (await placed.locator('.ldh-block-head .ttl').first().textContent().catch(() => '')).trim();
+ await marks.beat('H3-end', `the chart, a block of the page: ${placedTitle}`, await focus(placed));
+ await sleep(1000);
+
+ // ── H4 · the conversation stays ─────────────────────────────────────────────
+ await marks.beat('H4-start', 'the page reloaded');
+ await page.reload({ waitUntil: 'load' });
+ await until(chat().locator('.ldh-chat-turn').nth(1).waitFor({ state: 'visible', timeout: 60_000 }), 'the stored chat with both turns');
+ await sleep(2500);
+ const turns = await chat().locator('.ldh-chat-turn').allTextContents();
+ await easeScrollTo(chat(), { ms: 1600, margin: 200 }); await sleep(2000);
+ await marks.beat('H4-chat', `the chat block, drawn from the store: ${JSON.stringify(turns)}`, await focus(chat()));
+ await easeScrollTo(composer(), { ms: 1600, block: 'center' }); await sleep(2500);
+ await marks.beat('H4-end', 'both turns and the composer under them', await focus(chat()));
+ await sleep(800);
+ await marks.beat('end');
+ },
+});
+
+// The scratch document goes once the take is recorded, so the other takes' document tree shows
+// the demo's own documents only.
+await removeAll({ ...opts, urls: [doc.url] });
+console.log(` removed: ${doc.url}`);
diff --git a/screencast/scenes/probe-assistant.mjs b/screencast/scenes/probe-assistant.mjs
new file mode 100644
index 00000000..ff303bb0
--- /dev/null
+++ b/screencast/scenes/probe-assistant.mjs
@@ -0,0 +1,83 @@
+// Probe for flow H of the overview: what the live assistant does with the film's two
+// questions on a scratch Northwind document — whether the read runs at once, how its
+// result is drawn, whether the follow-up writes a view and waits for Execute, and how
+// long each takes. Prints what it saw; asserts nothing.
+//
+// node scenes/probe-assistant.mjs --base … --cert-file … --cert-password-file … --no-video
+// [--ask "…"] [--follow "…"] [--execute]
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+import { resetDocument } from '../lib/fixture.mjs';
+import { ui } from '../lib/dom.mjs';
+import { GEOMETRY_2X, zoom2x, load } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const argv = process.argv.slice(2);
+const value = (name, fallback) => { const i = argv.indexOf(`--${name}`); return i === -1 ? fallback : argv[i + 1]; };
+const ASK = value('ask', 'Chart the five customers who ordered the most');
+const FOLLOW = value('follow', 'Add them to this page as a view');
+const EXECUTE = argv.includes('--execute');
+
+const doc = await resetDocument({ ...opts, container: `${base}/`, slug: 'overview-assistant', title: 'Our best customers' });
+const CONTENT = `${doc.url}?mode=${encodeURIComponent('https://w3id.org/atomgraph/linkeddatahub#ContentMode')}`;
+console.log(` scratch: ${doc.url}`);
+
+const t0 = Date.now();
+const at = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
+
+await runScene({
+ id: 'probe-assistant', target: CONTENT, warm: CONTENT, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page }) {
+ await zoom2x(page);
+ await load(page, CONTENT, '.ldh-pane.is-active .ldh-create-dock .ldh-chat-open', 2000);
+ const content = ui(page).locator('.content-body').filter({ has: page.locator('> .ldh-create-dock') }).first();
+ const button = content.locator('> .ldh-create-dock .ldh-chat-open');
+ console.log(` ${at()} Assistant button: ${await button.count()}`);
+ await button.click();
+ const chatUri = await content.locator('> .ldh-chat-ephemeral .ldh-chat').getAttribute('data-chat');
+ const chat = () => page.locator(`.ldh-chat[data-chat="${chatUri}"]`);
+ const composer = () => chat().locator('> form.ldh-chat-composer textarea');
+ const card = () => chat().locator('.ldh-chat-log > .ldh-chat-plan').last();
+ console.log(` ${at()} execute by default: ${await chat().locator('.ldh-chat-run input').isChecked().catch(() => '?')}`);
+
+ const report = async (label) => {
+ const c = card();
+ const steps = await c.locator('.ldh-chat-step').evaluateAll((els) => els.map((e) => `${e.querySelector('.op')?.textContent?.trim()}:${[...e.classList].filter((k) => k.startsWith('is-')).join(',')}`));
+ const wells = await c.locator('.ldh-chat-result-block').evaluateAll((els) => els.map((e) => e.getAttribute('data-mode')?.split('#')[1]));
+ console.log(` ${at()} ${label}`);
+ console.log(` summary: ${(await c.locator('.ldh-chat-plan-meta, .ldh-chat-summary').first().textContent().catch(() => '')).replace(/\s+/g, ' ').trim().slice(0, 200)}`);
+ console.log(` answer: ${(await c.locator('.ldh-chat-answer').textContent().catch(() => '')).trim().slice(0, 300)}`);
+ console.log(` steps: ${steps.join(' ')}`);
+ console.log(` result wells: ${JSON.stringify(wells)}; svg: ${await c.locator('svg').count()}; table rows: ${await c.locator('table tbody tr').count()}`);
+ console.log(` docs written: ${JSON.stringify(await c.locator('.ldh-chat-docs a.iri').evaluateAll((els) => els.map((e) => e.getAttribute('href'))))}`);
+ console.log(` execute button: ${await c.locator('.ldh-chat-execute').isVisible().catch(() => false)}`);
+ };
+
+ // the read: expected to run on arrival and end in an answer
+ await composer().fill(ASK);
+ await composer().press('Enter');
+ await card().locator('.ldh-chat-answer:not(:empty), .ldh-chat-execute, .ldh-chat-step.is-failed').first().waitFor({ timeout: 240_000 }).catch(() => {});
+ await sleep(4000);
+ await report(`ASK: ${ASK}`);
+ await page.screenshot({ path: 'shots/probe-assistant-1.png' });
+
+ // the follow-up: expected to write, so to wait for Execute
+ await composer().fill(FOLLOW);
+ await composer().press('Enter');
+ await card().locator('.ldh-chat-execute, .ldh-chat-answer:not(:empty), .ldh-chat-step.is-failed').first().waitFor({ timeout: 240_000 }).catch(() => {});
+ await sleep(2000);
+ await report(`FOLLOW: ${FOLLOW}`);
+ await page.screenshot({ path: 'shots/probe-assistant-2.png' });
+
+ if (EXECUTE && await card().locator('.ldh-chat-execute').isVisible().catch(() => false)) {
+ await card().locator('.ldh-chat-execute').click();
+ await card().locator('.ldh-chat-answer:not(:empty), .ldh-chat-step.is-failed').first().waitFor({ timeout: 240_000 }).catch(() => {});
+ await sleep(8000);
+ await report('EXECUTED');
+ const blocks = await content.locator('> .ldh-block-row').evaluateAll((els) => els.map((e) => `${e.getAttribute('about')?.split('#')[1]} ${e.querySelector('.ldh-block-head .ttl')?.textContent?.trim() ?? ''}`));
+ console.log(` ${at()} blocks on the page: ${JSON.stringify(blocks)}`);
+ await page.screenshot({ path: 'shots/probe-assistant-3.png' });
+ }
+ },
+});
diff --git a/screencast/scenes/probe-drop-signin.mjs b/screencast/scenes/probe-drop-signin.mjs
new file mode 100644
index 00000000..85b82d46
--- /dev/null
+++ b/screencast/scenes/probe-drop-signin.mjs
@@ -0,0 +1,84 @@
+// Two things the overview needs that nothing has filmed yet.
+//
+// C3's second lane: a Turtle file dropped on a document. The file-drop overlay takes
+// any file; RDF is imported into the document the drop landed on. What does the page
+// show afterwards, and without a reload? Writes into a scratch document it removes.
+//
+// F4: the sign-in screen, seen anonymously — the Login control in the navigation bar
+// and what it opens. Read-only.
+import fs from 'node:fs/promises';
+import { chromium } from 'playwright';
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+import { resetDocument } from '../lib/fixture.mjs';
+import { spawn } from 'node:child_process';
+
+const opts = await resolve('/');
+const { base, identity, ldh, certFile, certPasswordFile, certPassword } = opts;
+const password = certPassword ?? (await fs.readFile(certPasswordFile, 'utf8')).trim();
+const run = (args) => new Promise((res) => { const p = spawn(ldh, args); let out = ''; p.stdout.on('data', (d) => (out += d)); p.on('close', (code) => res({ code, out })); });
+
+const slug = `drop-probe-${Date.now().toString(36)}`;
+const { url } = await resetDocument({ ...opts, container: `${base}/`, slug, title: 'Drop probe' });
+
+const ttl = `@prefix schema: .
+@prefix dct: .
+<#this> a schema:Place ; schema:name "Rockville" ; schema:description "A territory dropped in as Turtle." .
+`;
+
+await runScene({
+ id: 'probe-drop-signin', target: url, identity,
+ geometry: geometryFrom(opts, { width: 1440, height: 900, deviceScaleFactor: 1 }),
+ async body({ page, cursor, shot }) {
+ const responses = [];
+ page.on('response', (r) => { if (['PUT', 'POST', 'PATCH'].includes(r.request().method()) && r.url().startsWith(base)) responses.push(`${r.request().method()} ${r.status()} ${r.url().slice(base.length, base.length + 60)}`); });
+ await page.goto(url, { waitUntil: 'load' });
+ await page.waitForSelector('a.document-history, .action-bar', { timeout: 25_000 });
+ await sleep(3000);
+ await shot('before');
+
+ const transfer = await page.evaluateHandle(({ name, type, text }) => {
+ const t = new DataTransfer();
+ t.items.add(new File([text], name, { type }));
+ return t;
+ }, { name: 'rockville.ttl', type: 'text/turtle', text: ttl });
+ const { width, height } = page.viewportSize();
+ await cursor.moveTo(width * 0.5, height * 0.55, { duration: 600 });
+ await page.dispatchEvent('body', 'dragenter', { dataTransfer: transfer });
+ await page.locator('#file-drop').waitFor({ timeout: 5000 });
+ await sleep(800);
+ console.log(' overlay text:', JSON.stringify(await page.locator('#file-drop').innerText().then((t) => t.replace(/\s+/g, ' ').slice(0, 200))));
+ await shot('overlay');
+ await page.dispatchEvent('#file-drop', 'drop', { dataTransfer: transfer });
+ await sleep(9000);
+ console.log(' writes:', JSON.stringify(responses));
+ console.log(' url now:', page.url());
+ console.log(' mode in url:', decodeURIComponent(page.url()).match(/mode=[^&]*/)?.[0] ?? '-');
+ const text = await page.locator('.ldh-pane.is-active, main').first().innerText().catch(() => '');
+ console.log(' page mentions Rockville:', /Rockville/.test(text), '| dropped-in description:', /dropped in as Turtle/.test(text));
+ await shot('after-drop');
+ const got = await run(['get', '--accept', 'text/turtle', url, '-c', certFile, '-p', password]);
+ console.log(' stored graph has Rockville:', /Rockville/.test(got.out));
+ },
+});
+console.log(' deleted scratch:', (await run(['delete', url, '-c', certFile, '-p', password])).code === 0);
+
+// The sign-in screen, anonymously.
+const browser = await chromium.launch();
+const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 }, colorScheme: 'dark' });
+await ctx.addCookies([{ name: 'LinkedDataHub.first-time-message', value: 'true', domain: new URL(base).hostname, path: '/' }]);
+const page = await ctx.newPage();
+await page.goto(base + '/', { waitUntil: 'load' });
+await page.waitForTimeout(5000);
+const login = page.locator('button, a').filter({ hasText: /Log in|Login|Sign in/i });
+console.log(' login controls:', await login.count(), JSON.stringify(await login.evaluateAll((es) => es.map((e) => e.tagName + ' ' + e.className.slice(0, 40) + ' ' + e.textContent.trim().slice(0, 30)))));
+await fs.mkdir('shots/probe-drop-signin', { recursive: true });
+await page.screenshot({ path: 'shots/probe-drop-signin/anon-home.png' });
+if (await login.count()) {
+ await login.first().click();
+ await page.waitForTimeout(2500);
+ const menu = page.locator('[role=menu]:visible, .ac-dropdown:visible, .ac-modal:visible').first();
+ console.log(' after click:', JSON.stringify(await menu.innerText().then((t) => t.replace(/\s+/g, ' ').slice(0, 300)).catch(() => 'nothing opened')));
+ console.log(' links:', JSON.stringify(await menu.locator('a, button').evaluateAll((es) => es.map((e) => (e.textContent || '').trim().slice(0, 30) + ' → ' + (e.getAttribute('href') || e.className).slice(0, 60))).catch(() => [])));
+ await page.screenshot({ path: 'shots/probe-drop-signin/login-open.png' });
+}
+await browser.close();
diff --git a/screencast/scenes/probe-edit-constructors.mjs b/screencast/scenes/probe-edit-constructors.mjs
new file mode 100644
index 00000000..c2397c4e
--- /dev/null
+++ b/screencast/scenes/probe-edit-constructors.mjs
@@ -0,0 +1,35 @@
+// Probe for E3c of the overview: what Edit constructors opens from a product's edit form —
+// the dialog's heading, its rows and controls — so the narration describes it as it is.
+// Prints what it saw and leaves screenshots in shots/; changes nothing.
+//
+// node scenes/probe-edit-constructors.mjs --base … --cert-file … --cert-password-file … --no-video
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+import { editResource } from '../lib/editing.mjs';
+import { GEOMETRY_2X, zoom2x, load } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const READ = `${base}/products/1/?mode=${encodeURIComponent('https://w3id.org/atomgraph/client#ReadMode')}`;
+
+await runScene({
+ id: 'probe-edit-constructors', target: READ, warm: READ, identity,
+ geometry: geometryFrom(opts, GEOMETRY_2X),
+ async body({ page, cursor }) {
+ await zoom2x(page);
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await load(page, READ, '.ldh-pane.is-active .ldh-block', 3000);
+ const ed = await editResource(page, cursor, 'Chai');
+ console.log(` edit form: ${ed.ok ? 'open' : ed.why}`);
+ const btn = page.locator('button.btn-edit-constructors:visible').first();
+ await btn.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {});
+ console.log(` Edit constructors visible: ${await btn.isVisible().catch(() => false)}`);
+ await page.screenshot({ path: 'shots/probe-edit-constructors-1.png' });
+ await cursor.click(btn);
+ await sleep(5000);
+ const dialog = page.locator('.modal:visible, dialog[open], [role=dialog]:visible').last();
+ console.log(` dialog: ${await dialog.count()}`);
+ const text = (await dialog.innerText().catch(() => '')).replace(/\n{2,}/g, '\n').slice(0, 1500);
+ console.log(text.split('\n').map((l) => ` | ${l}`).join('\n'));
+ await page.screenshot({ path: 'shots/probe-edit-constructors-2.png' });
+ },
+});
diff --git a/screencast/scenes/probe-restore.mjs b/screencast/scenes/probe-restore.mjs
new file mode 100644
index 00000000..e6158354
--- /dev/null
+++ b/screencast/scenes/probe-restore.mjs
@@ -0,0 +1,69 @@
+// Restore on its own, from the current document rather than from inside a comparison:
+// the versions probe got a 412 when Restore was pressed while the page was showing a
+// historical version, so this one loads the document fresh, opens History and restores
+// the previous version straight away. A new slug gives it a history of exactly three
+// versions. Writes only to its own scratch document, which it removes at the end.
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+import { resetDocument } from '../lib/fixture.mjs';
+
+const opts = await resolve('/');
+const { base, identity, ldh, certFile, certPasswordFile, certPassword } = opts;
+const password = certPassword ?? (await fs.readFile(certPasswordFile, 'utf8')).trim();
+const auth = ['-c', certFile, '-p', password];
+const run = (args, input = null) => new Promise((res) => {
+ const p = spawn(ldh, args);
+ if (input !== null) { p.stdin.write(input); p.stdin.end(); }
+ let out = '', err = '';
+ p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d));
+ p.on('close', (code) => res({ code, out: out.trim(), err: err.trim().split(password).join('••••') }));
+});
+
+const slug = `restore-probe-${Date.now().toString(36)}`;
+const { url } = await resetDocument({ ...opts, container: `${base}/`, slug, title: 'Restore probe, first title' });
+for (const title of ['Restore probe, second title', 'Restore probe, third title']) {
+ const got = await run(['get', '--accept', 'text/turtle', url, ...auth]);
+ const put = await run(['put', '-t', 'text/turtle', url, ...auth], got.out.replace(/"Restore probe, [a-z]+ title"/, JSON.stringify(title)));
+ console.log(` put "${title}" →`, put.code === 0 ? 'ok' : put.err.slice(0, 200));
+ await sleep(2500);
+}
+await sleep(4000);
+
+await runScene({
+ id: 'probe-restore', target: url, identity,
+ geometry: geometryFrom(opts, { width: 1440, height: 900, deviceScaleFactor: 1 }),
+ async body({ page, cursor, shot }) {
+ const responses = [];
+ page.on('response', (r) => { if (['PUT', 'POST', 'PATCH'].includes(r.request().method())) responses.push(`${r.request().method()} ${r.status()} ${r.url().slice(0, 90)} if-match=${r.request().headers()['if-match'] ?? '-'}`); });
+
+ await page.goto(url, { waitUntil: 'load' });
+ await page.waitForSelector('a.document-history', { timeout: 25_000 });
+ await sleep(3000);
+ console.log(' title shown:', JSON.stringify(await page.locator('.ldh-bc, .action-bar').first().innerText().then((t) => t.replace(/\s+/g, ' ').slice(0, 80))));
+
+ await cursor.click(page.locator('a.document-history').first());
+ const modal = page.locator('.ac-modal:visible').last();
+ await modal.waitFor({ state: 'visible', timeout: 15000 });
+ await sleep(3500);
+ const rows = modal.locator('tr').filter({ has: page.locator('input[type=radio]') });
+ console.log(' version rows:', await rows.count());
+ await shot('history');
+
+ // Restore the version before the current one (second row).
+ const restore = rows.nth(1).locator('button.btn-restore');
+ console.log(' restore value:', await restore.getAttribute('value'));
+ page.once('dialog', (d) => { console.log(' confirm:', d.message()); d.accept(); });
+ await cursor.click(restore);
+ await sleep(8000);
+ console.log(' writes:', JSON.stringify(responses));
+ console.log(' modal still open:', await page.locator('.ac-modal:visible').count());
+ console.log(' alert in modal:', JSON.stringify(await page.locator('.ac-modal:visible [class*="alert"], .ac-modal:visible [role=alert]').allInnerTexts().catch(() => [])));
+ await shot('after-restore');
+ const got = await run(['get', '--accept', 'text/turtle', url, ...auth]);
+ console.log(' stored title now:', (got.out.match(/"Restore probe, [a-z]+ title"/) || ['?'])[0]);
+ console.log(' page title now:', JSON.stringify(await page.locator('.ldh-bc, .action-bar').first().innerText().then((t) => t.replace(/\s+/g, ' ').slice(0, 80))));
+ await sleep(1000);
+ },
+});
+console.log(' deleted scratch:', (await run(['delete', url, ...auth])).code === 0);
diff --git a/screencast/scenes/probe-scheme-width.mjs b/screencast/scenes/probe-scheme-width.mjs
new file mode 100644
index 00000000..b1a6fd4f
--- /dev/null
+++ b/screencast/scenes/probe-scheme-width.mjs
@@ -0,0 +1,53 @@
+// Read-only: for the overview's E4 — does the page follow a colour-scheme change live,
+// without a reload, and what does the recording show when the viewport narrows to a
+// phone width mid-take? Stills of each state, plus the recorded video's frame size.
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+
+const opts = await resolve('/territories/');
+const { target, identity } = opts;
+
+const r = await runScene({
+ id: 'probe-scheme-width', target, identity,
+ geometry: geometryFrom(opts, { width: 1440, height: 900, deviceScaleFactor: 1 }),
+ async body({ page, shot }) {
+ const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
+ const scheme = () => page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await page.goto(target, { waitUntil: 'load' });
+ await page.waitForSelector('.ldh-pane.is-active .ldh-block, canvas', { timeout: 30_000 });
+ await sleep(5000);
+ console.log(' dark:', await scheme(), await bg());
+ await shot('dark');
+
+ // The switch, live: no navigation, just the media feature changing under the page.
+ await page.emulateMedia({ colorScheme: 'light' });
+ await sleep(1500);
+ console.log(' after switch:', await scheme(), await bg(), 'url unchanged:', page.url() === target);
+ await shot('light');
+
+ // Narrow to a phone, in two steps so the responsive breakpoints at 1024 and 768 show.
+ for (const width of [1000, 740, 390]) {
+ await page.setViewportSize({ width, height: 900 });
+ await sleep(2500);
+ const layout = await page.evaluate(() => ({
+ bodyW: document.body.clientWidth, scrollW: document.documentElement.scrollWidth,
+ drawerOpen: !!document.querySelector('.ldh-drawer.is-open, [class*="drawer"][aria-expanded="true"]'),
+ actionBarTiers: document.querySelectorAll('.ldh-actionbar, [class*="action-bar"], [class*="actionbar"]').length,
+ }));
+ console.log(` width ${width}:`, JSON.stringify(layout));
+ await shot(`w${width}`);
+ }
+ await sleep(1500);
+ },
+});
+
+// What did the video record while the viewport was narrower than the recording?
+import { spawnSync } from 'node:child_process';
+if (r.trackPath) {
+ const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', r.trackPath]);
+ console.log(' video frame:', probe.stdout.toString().trim());
+ const t = r.marks.entries.at(-1)?.at ?? 20;
+ spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-ss', String(Math.max(0, t - 1.5)), '-i', r.trackPath, '-frames:v', '1', 'shots/probe-scheme-width/video-at-390.png']);
+ console.log(' last video frame written to shots/probe-scheme-width/video-at-390.png');
+}
diff --git a/screencast/scenes/probe-versions.mjs b/screencast/scenes/probe-versions.mjs
new file mode 100644
index 00000000..e19e67cc
--- /dev/null
+++ b/screencast/scenes/probe-versions.mjs
@@ -0,0 +1,106 @@
+// Versioning, end to end, for the overview's flow D: a scratch document is written
+// three times off camera through ldh (create, then two PUTs with different titles),
+// so the History dialog has versions to show; then the dialog is opened, two versions
+// are compared, and the oldest is restored. Logs what each step found and stills each
+// state, so the shot can be scripted against what the dialog really renders.
+//
+// Writes only to its own scratch document, which it removes at the end.
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs';
+import { resetDocument } from '../lib/fixture.mjs';
+
+const opts = await resolve('/');
+const { base, identity, ldh, certFile, certPasswordFile, certPassword } = opts;
+const password = certPassword ?? (await fs.readFile(certPasswordFile, 'utf8')).trim();
+const auth = ['-c', certFile, '-p', password];
+
+const run = (args, input = null) => new Promise((res) => {
+ const p = spawn(ldh, args);
+ if (input !== null) { p.stdin.write(input); p.stdin.end(); }
+ let out = '', err = '';
+ p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d));
+ p.on('close', (code) => res({ code, out: out.trim(), err: err.trim().split(password).join('••••') }));
+});
+
+const slug = 'versioning-probe';
+const { url } = await resetDocument({ ...opts, container: `${base}/`, slug, title: 'Versioning probe, first title' });
+console.log(' created', url);
+
+// Two more writes, each a full PUT of the document with a different title, so the
+// history holds three versions that differ in one visible property.
+for (const title of ['Versioning probe, second title', 'Versioning probe, third title']) {
+ const got = await run(['get', '--accept', 'text/turtle', url, ...auth]);
+ if (got.code !== 0) throw new Error(`get failed: ${got.err}`);
+ const ttl = got.out.replace(/"Versioning probe, [a-z]+ title"/, JSON.stringify(title));
+ const put = await run(['put', '-t', 'text/turtle', url, ...auth], ttl);
+ console.log(` put "${title}" →`, put.code === 0 ? 'ok' : put.err.slice(0, 200));
+ await sleep(2500); // commits are asynchronous and chained; give each one room
+}
+await sleep(4000);
+
+await runScene({
+ id: 'probe-versions', target: url, identity,
+ geometry: geometryFrom(opts, { width: 1440, height: 900, deviceScaleFactor: 1 }),
+ async body({ page, cursor, shot }) {
+ await page.goto(url, { waitUntil: 'load' });
+ // A bare scratch item renders no view block; the history link in the action bar is
+ // the thing this probe is about, so that is what it waits for.
+ await page.waitForSelector('a.document-history', { timeout: 25_000 });
+ await sleep(3000);
+ await shot('page');
+ console.log(' page blocks:', JSON.stringify(await page.locator('.ldh-pane.is-active [class*="ldh-"]').evaluateAll((es) => [...new Set(es.map((e) => e.className.split(' ')[0]))].slice(0, 12))));
+
+ const link = page.locator('a.document-history').first();
+ console.log(' history link:', await link.count(), await link.evaluate((e) => e.outerHTML.slice(0, 200)).catch(() => 'none'));
+ await cursor.click(link);
+ const modal = page.locator('.ac-modal:visible').last();
+ await modal.waitFor({ state: 'visible', timeout: 15000 });
+ await sleep(4000);
+ await shot('history');
+
+ const rows = modal.locator('tr').filter({ has: page.locator('input[type=radio]') });
+ console.log(' version rows:', await rows.count());
+ console.log(' row texts:', JSON.stringify(await rows.allInnerTexts()));
+ console.log(' buttons:', JSON.stringify(await modal.locator('button, a.ac-btn').allInnerTexts()));
+ console.log(' per-row controls:', JSON.stringify(await rows.first().locator('button, a, input').evaluateAll((es) => es.map((e) => e.tagName + ' ' + (e.className || '') + ' ' + (e.textContent || e.value || '').trim().slice(0, 30)))));
+
+ // Compare: oldest as From, newest as To.
+ const n = await rows.count();
+ if (n >= 2) {
+ await cursor.click(rows.last().locator('input[type=radio]').first()); await sleep(500);
+ await cursor.click(rows.first().locator('input[type=radio]').last()); await sleep(500);
+ await shot('picked');
+ const compare = modal.locator('button').filter({ hasText: /Compare/ }).last();
+ console.log(' compare visible:', await compare.isVisible().catch(() => false));
+ await cursor.click(compare);
+ await sleep(6000);
+ await page.evaluate(() => window.scrollTo(0, 0)); await sleep(800);
+ const marked = await page.locator('[class*="added"], [class*="removed"], [class*="changed"]').count();
+ console.log(' diff marks on the page:', marked);
+ console.log(' diff classes:', JSON.stringify(await page.locator('[class*="added"], [class*="removed"], [class*="changed"]').evaluateAll((es) => [...new Set(es.map((e) => e.className))].slice(0, 8))));
+ await shot('diff');
+ }
+
+ // Restore: reopen the dialog, find the restore control on a version that is not current.
+ await cursor.click(page.locator('a.document-history').first());
+ await modal.waitFor({ state: 'visible', timeout: 15000 });
+ await sleep(3000);
+ const restore = modal.locator('button, a').filter({ hasText: /Restore/ });
+ console.log(' restore controls:', await restore.count(), JSON.stringify(await restore.evaluateAll((es) => es.map((e) => e.outerHTML.slice(0, 160)))));
+ if (await restore.count()) {
+ page.once('dialog', (d) => { console.log(' confirm dialog:', d.message()); d.accept(); });
+ await cursor.click(restore.last());
+ await sleep(8000);
+ console.log(' modal still open:', await page.locator('.ac-modal:visible').count());
+ const title = await page.locator('.ldh-pane.is-active h1, .ldh-pane.is-active .ldh-block h2').first().innerText().catch(() => '?');
+ console.log(' title after restore:', JSON.stringify(title));
+ const got = await run(['get', '--accept', 'text/turtle', url, ...auth]);
+ console.log(' stored title after restore:', (got.out.match(/"Versioning probe, [a-z]+ title"/) || ['?'])[0]);
+ await shot('restored');
+ }
+ },
+});
+
+const gone = await run(['delete', url, ...auth]);
+console.log(' deleted scratch:', gone.code === 0);
diff --git a/screencast/scenes/x-header.mjs b/screencast/scenes/x-header.mjs
new file mode 100644
index 00000000..3e21bc6e
--- /dev/null
+++ b/screencast/scenes/x-header.mjs
@@ -0,0 +1,138 @@
+// The AtomGraph X (Twitter) header: a document in Graph mode, as the overview film opens -
+// rendered as a still at 3:1. X takes a 1500×500 image, so the page is laid out at 3000×1000 and
+// each still is downscaled 2:1 for sharpness.
+//
+// The graph is maximised by the view's own Fullscreen button, its controls, the pointer and every
+// label sprite hidden (the labels are dataspace URIs, which have no place on a banner), on the
+// page's own dark ground. Once the force layout has settled, the camera is turned round the graph
+// and a still is taken every 30°, so the framing is picked from the stills rather than guessed.
+//
+// node scenes/x-header.mjs --base … --cert-file … --cert-password-file … [--doc /orders/10423/ | --uri ] [--labels] [--label-scale 1.5] [--shift 0.2] [--zoom 0.85] [--align]
+//
+// Writes shots/x-header/-.png at 1500×500.
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { runScene, resolve, sleep, until, SHOTS } from '../lib/harness.mjs';
+import { load } from '../lib/supercut.mjs';
+
+const opts = await resolve('/');
+const { base, identity } = opts;
+const argv = process.argv.slice(2);
+const DOC = argv.includes('--doc') ? argv[argv.indexOf('--doc') + 1] : '/orders/10423/';
+// --uri: an external document, opened through the Linked Data browser instead of a dataspace document
+const URI = argv.includes('--uri') ? argv[argv.indexOf('--uri') + 1] : null;
+// --labels: keep the label sprites; a vocabulary's labels are words, a dataspace's are URIs
+const LABELS = argv.includes('--labels');
+const value = (name, fallback) => (argv.includes(`--${name}`) ? Number(argv[argv.indexOf(`--${name}`) + 1]) : fallback);
+// --label-scale: labels drawn larger than the app draws them, to stay legible at 1500×500
+const LABEL_SCALE = value('label-scale', 1);
+// --shift: the share of the orbit radius the aim moves left, so the graph sits right of centre,
+// clear of the profile picture X lays over the header's bottom-left
+const SHIFT = value('shift', 0);
+// --zoom: the camera's distance as a share of the fitted one; under 1 the dense core fills the band and
+// the outliers (the RDF built-ins a vocabulary points at) fall off its edges
+const ZOOM = value('zoom', 0.85);
+// --align: orbit round the graph's longest axis and keep that axis horizontal, so the graph's widest
+// spread runs along the 3:1 band whatever the angle
+const ALIGN = argv.includes('--align');
+const GRAPH_MODE = encodeURIComponent('https://w3id.org/atomgraph/client#GraphMode');
+const TARGET = URI ? `${base}/?uri=${encodeURIComponent(URI)}&mode=${GRAPH_MODE}` : `${base}${DOC}?mode=${GRAPH_MODE}`;
+const OUT = path.join(SHOTS, 'x-header');
+const NAME = (URI ? URI.replace(/^https?:\/\//, '') : DOC).replace(/^\/|[\/#]+$/g, '').replace(/[\/#.]+/g, '-');
+await fs.mkdir(OUT, { recursive: true });
+
+const downscale = (src, dst) => new Promise((res, rej) => {
+ const p = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-i', src, '-vf', 'scale=1500:500:flags=lanczos', dst], { stdio: 'inherit' });
+ p.on('close', (code) => (code ? rej(new Error(`ffmpeg exited ${code}`)) : res()));
+});
+
+await runScene({
+ id: 'x-header', target: TARGET, warm: TARGET, identity,
+ geometry: { width: 3000, height: 1000, deviceScaleFactor: 1 },
+ async body({ page }) {
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await load(page, TARGET, 'canvas', 3000);
+ await page.locator('button.graph-3d-fullscreen').first().click();
+ await until(page.waitForFunction(() => !!document.querySelector('.graph-3d-maximized'), null, { timeout: 10_000 }), 'the graph maximised');
+ await page.addStyleTag({ content: `
+ .graph-3d-show-panel, .graph-3d-info-panel, .graph-3d-tooltip,
+ .graph-3d-zoom, .graph-3d-fullscreen, .scene-nav-info { display: none !important; }
+ .graph-3d-canvas.graph-3d-maximized { height: 100vh !important; }
+ #__ldh-cursor { visibility: hidden !important; }` });
+ // let the force layout settle before anything is framed
+ await sleep(12_000);
+
+ const setup = await page.evaluate(([labels, scale]) => {
+ const entries = Object.entries(window.LinkedDataHub?.graphs ?? {});
+ const live = entries.filter(([id]) => document.getElementById(id)?.closest('.graph-3d-maximized')).at(-1) ?? entries.at(-1);
+ if (!live) return { ok: false };
+ const fg = live[1].instance ?? live[1];
+ window.__fg = fg;
+ fg.width(innerWidth).height(innerHeight).backgroundColor(getComputedStyle(document.body).backgroundColor);
+ // A node's label sits in the node's group beside its mesh; a link's label is the link's own object.
+ // Only the words stay: a node labelled with a full URI (a term the vocabulary merely points at)
+ // and every link label go.
+ // The app draws node labels 5 high and link labels 4 (client/graph3d.xsl), which tells them apart.
+ window.__labels = () => fg.scene().traverse((o) => {
+ if (!o.isSprite) return;
+ o.userData.h0 ??= o.textHeight;
+ const onNode = o.userData.h0 >= 5;
+ if (!labels || !onNode || /^https?:/.test(o.text ?? '')) { o.visible = false; return; }
+ if (scale !== 1 && !o.userData.scaled) { o.textHeight = o.userData.h0 * scale; o.userData.scaled = true; }
+ // drawn over the spheres: enlarged, a label would otherwise sink into its own node
+ o.material.depthTest = false; o.renderOrder = 999;
+ });
+ window.__labels();
+ fg.zoomToFit(0, 40);
+ const box = fg.getGraphBbox();
+ const c = { x: (box.x[0] + box.x[1]) / 2, y: (box.y[0] + box.y[1]) / 2, z: (box.z[0] + box.z[1]) / 2 };
+ const cam = fg.cameraPosition();
+ window.__orbit = { c, r: Math.hypot(cam.x - c.x, cam.z - c.z), a0: Math.atan2(cam.x - c.x, cam.z - c.z), y: cam.y };
+ // the longest axis: the principal component of the node positions, by power iteration
+ const ns = fg.graphData().nodes.filter((n) => Number.isFinite(n.x));
+ const m = ns.reduce((acc, n) => ({ x: acc.x + n.x / ns.length, y: acc.y + n.y / ns.length, z: acc.z + n.z / ns.length }), { x: 0, y: 0, z: 0 });
+ const cov = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
+ for (const n of ns) { const d = [n.x - m.x, n.y - m.y, n.z - m.z]; for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) cov[i][j] += d[i] * d[j]; }
+ let p = [1, 0.3, 0.2];
+ for (let k = 0; k < 50; k++) { const q = cov.map((row) => row[0] * p[0] + row[1] * p[1] + row[2] * p[2]); const l = Math.hypot(...q); p = q.map((v) => v / l); }
+ window.__axis = { m, p };
+ return { ok: true, nodes: fg.graphData().nodes.length, links: fg.graphData().links.length };
+ }, [LABELS, LABEL_SCALE]);
+ if (!setup.ok) throw new Error('no graph instance');
+ console.log(` ${URI ?? DOC}: ${setup.nodes} nodes, ${setup.links} links`);
+
+ for (let deg = 0; deg < 360; deg += 30) {
+ await page.evaluate(([d, labels, shift, zoom, align]) => {
+ const { c, r, a0, y } = window.__orbit;
+ const a = a0 + (d * Math.PI) / 180;
+ if (align) {
+ // a camera circling the axis p through the nodes' centroid, its up perpendicular to p, so p
+ // lies across the frame; the aim slides along p to put the graph right of centre
+ const { m, p } = window.__axis;
+ const cross = (u, v) => [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
+ const norm = (u) => { const l = Math.hypot(...u); return u.map((x) => x / l); };
+ const u = norm(cross(p, Math.abs(p[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]));
+ const w = cross(p, u);
+ const dir = [0, 1, 2].map((i) => Math.cos(a) * u[i] + Math.sin(a) * w[i]);
+ const aim = { x: m.x + shift * r * p[0], y: m.y + shift * r * p[1], z: m.z + shift * r * p[2] };
+ const up = norm(cross(p, dir));
+ window.__fg.camera().up.set(up[0], up[1], up[2]);
+ window.__fg.cameraPosition({ x: aim.x + zoom * r * dir[0], y: aim.y + zoom * r * dir[1], z: aim.z + zoom * r * dir[2] }, aim, 0);
+ window.__labels();
+ return;
+ }
+ // the camera's right in the orbit plane; aiming left of centre puts the graph right of it
+ const aim = { x: c.x - shift * r * Math.cos(a), y: c.y, z: c.z + shift * r * Math.sin(a) };
+ window.__fg.cameraPosition({ x: aim.x + zoom * r * Math.sin(a), y, z: aim.z + zoom * r * Math.cos(a) }, aim, 0);
+ window.__labels();
+ }, [deg, LABELS, SHIFT, ZOOM, ALIGN]);
+ await sleep(700);
+ const raw = path.join(OUT, `${NAME}-${String(deg).padStart(3, '0')}-3000.png`);
+ await page.screenshot({ path: raw });
+ await downscale(raw, path.join(OUT, `${NAME}-${String(deg).padStart(3, '0')}.png`));
+ await fs.rm(raw);
+ }
+ console.log(` stills: ${OUT}/${NAME}-*.png`);
+ },
+});
diff --git a/screencast/tapes/overview-f-get.tape b/screencast/tapes/overview-f-get.tape
new file mode 100644
index 00000000..e990cdca
--- /dev/null
+++ b/screencast/tapes/overview-f-get.tape
@@ -0,0 +1,17 @@
+# overview F3b — "Every document has both an HTML and an RDF representation. Ask the home page for
+# Turtle, and you get Turtle."
+#
+# The home page the push just made public, asked for as Turtle with ldh get. LDH_BASE names the
+# dataspace and the CLI's credentials arrive in the environment. Geometry, framerate and typing
+# speed come from lib/terminal.mjs, which prepends them.
+
+Require ldh
+
+Hide
+Type "clear" Enter
+Sleep 1s
+Show
+
+Sleep 1s
+Type `ldh get --accept text/turtle "$LDH_BASE"` Enter
+Sleep 7s
diff --git a/screencast/tapes/overview-f-public.tape b/screencast/tapes/overview-f-public.tape
new file mode 100644
index 00000000..9d80bd1d
--- /dev/null
+++ b/screencast/tapes/overview-f-public.tape
@@ -0,0 +1,28 @@
+# overview F3 — "Access control uses W3C ACL authorizations, which are RDF too. Here the CLI creates an
+# Editors group, lets it write the user guide, and makes the dataspace public."
+#
+# Three admin commands against the running stack: an agent group with the owner as its member, an
+# authorization granting that group read and write on the user guide, and make-public. The group
+# and the authorization are removed off camera before the take, and the public-read authorization
+# reset, so every command has something to do.
+#
+# The CLI's credentials arrive in the environment (LDH_CERT_FILE, LDH_CERT_PASSWORD), as do the
+# dataspace (LDH_BASE), its administration dataspace (LDH_ADMIN_BASE) and the owner's WebID
+# (WEBID): the group and authorization commands take the administration base URI as their
+# positional, make-public defaults to LDH_BASE. Geometry, framerate and typing speed come from
+# lib/terminal.mjs, which prepends them.
+
+Require ldh
+
+Hide
+Type "clear" Enter
+Sleep 1s
+Show
+
+Sleep 1s
+Type `ldh admin create group --name Editors --slug editors --uri '#this' --member "$WEBID" "$LDH_ADMIN_BASE"` Enter
+Sleep 4s
+Type `ldh admin create authorization --label "Editors write the user guide" --slug editors-user-guide --agent-group "${LDH_ADMIN_BASE}acl/groups/editors/#this" --to "${LDH_BASE}user-guide/" --read --write "$LDH_ADMIN_BASE"` Enter
+Sleep 4s
+Type "ldh admin make-public" Enter
+Sleep 6s
diff --git a/screencast/tapes/overview-f-push.tape b/screencast/tapes/overview-f-push.tape
new file mode 100644
index 00000000..06d85255
--- /dev/null
+++ b/screencast/tapes/overview-f-push.tape
@@ -0,0 +1,27 @@
+# overview F1 — "Push a folder of Turtle files, and the dataspace is live."
+#
+# The documentation app pushed into an emptied dataspace: `ldh push` PUTs every RDF file to
+# the document its path spells and uploads every other file into its folder's document,
+# printing one URL per line. Run for real against the running stack; the dataspace was
+# emptied off camera so that what comes alive on screen was not there before.
+#
+# The working directory is the app (LDH_CLIP_DIR), the dataspace is LDH_BASE, and the CLI's
+# credentials arrive in the environment (LDH_CERT_FILE, LDH_CERT_PASSWORD), the way the
+# Makefile hands them to every ldh call — so the command typed is the one a person types.
+# Geometry, framerate and typing speed come from lib/terminal.mjs, which prepends them.
+
+Require ldh
+
+Hide
+Type "cd $LDH_CLIP_DIR && clear" Enter
+Sleep 1s
+Show
+
+Sleep 1s
+Type "ls" Enter
+Sleep 3s
+
+Type `ldh push --dir . "$LDH_BASE"` Enter
+# the push itself: measured at about twenty seconds for the docs app's 59 documents and
+# 44 uploads, and the streaming URLs are not frozen, so this plays at 1x
+Sleep 40s
diff --git a/screencast/tapes/overview-g-packages.tape b/screencast/tapes/overview-g-packages.tape
new file mode 100644
index 00000000..e6d79adb
--- /dev/null
+++ b/screencast/tapes/overview-g-packages.tape
@@ -0,0 +1,28 @@
+# overview G2 — "Installing one takes a single triple, written here from the command line."
+#
+# The catalogue listed, then the taxonomy editor package added to the thesaurus dataspace.
+# Both commands are the real ones against the running stack: the package is removed off
+# camera before the take, so `list` shows it as available and `add` genuinely installs it.
+#
+# The CLI's credentials arrive in the environment (LDH_CERT_FILE, LDH_CERT_PASSWORD), the
+# way the Makefile hands them to every ldh call, so the commands typed are the ones a
+# person would type. LDH_BASE names the dataspace, the
+# default of the base URI the packages commands take as their positional. Geometry, framerate and typing speed come
+# from lib/terminal.mjs, which prepends them.
+
+Require ldh
+
+Hide
+Type "clear" Enter
+Sleep 1s
+Show
+
+Sleep 1s
+Type "ldh packages list" Enter
+Sleep 6s
+
+Type "ldh packages add --package https://packages.linkeddatahub.com/editor/taxonomy/#this" Enter
+Sleep 6s
+
+Type "ldh packages list" Enter
+Sleep 6s
diff --git a/screencast/tapes/overview-h-agent.tape b/screencast/tapes/overview-h-agent.tape
new file mode 100644
index 00000000..4a0e6a1a
--- /dev/null
+++ b/screencast/tapes/overview-h-agent.tape
@@ -0,0 +1,53 @@
+# overview H5–H6 — "Outside the browser, an agent connects over MCP to the same operations,
+# through Web-Algebra." / "It is asked for something it has to declare rather than code …"
+#
+# Claude Code registers Web-Algebra as an MCP server with the command docs/get-started/agents.ttl
+# gives, run from the LinkedDataHub deployment's directory (TAKE_DIR) so the certificate paths in
+# it resolve; `/mcp` lists it connected; then the docs page's prompt, aimed at the Northwind
+# dataspace, and the agent's tool calls going by.
+#
+# Off camera, set up by scenes/overview-h-agent.mjs through the environment:
+# TAKE_WHEELS a find-links directory holding the Web-Algebra wheel the take runs, so the
+# docs' `uv run --with web-algebra` resolves to it rather than to PyPI
+# TAKE_EXCLUDE_NEWER the date the rest of its dependencies resolve as of: pydantic releases
+# after 2026-10-05 drop an internal the resolved mcp imports, and the server
+# then dies on start ("Connection closed" in /mcp)
+# TAKE_SETTINGS a settings file for this session only: the web-algebra tools allowed, Bash,
+# Write and Edit denied (so no permission prompt can stall the take), and the
+# Figma plugin off; with ENABLE_CLAUDEAI_MCP_SERVERS=false, `/mcp` then lists
+# web-algebra alone
+# The MCP server is removed again after the take, off camera.
+
+Require claude
+Require uv
+
+Hide
+Type `cd "$TAKE_DIR" && export UV_FIND_LINKS="$TAKE_WHEELS" UV_EXCLUDE_NEWER="$TAKE_EXCLUDE_NEWER" ENABLE_CLAUDEAI_MCP_SERVERS=false && alias claude='claude --settings "$TAKE_SETTINGS"' && export PS1='$ ' && clear` Enter
+Sleep 1s
+Show
+
+Sleep 1s
+Type `claude mcp add web-algebra --env CERT_PEM_PATH="$PWD/ssl/owner/cert.pem" --env CERT_PASSWORD="$(cat secrets/owner_cert_password.txt)" -- uv run --with web-algebra python -m web_algebra` Enter
+Sleep 3s
+
+Type "claude" Enter
+Sleep 8s
+# a folder Claude Code has not been trusted in asks first; Enter accepts, and is harmless otherwise
+Enter
+Sleep 3s
+
+Type "/mcp" Enter
+Sleep 5s
+Escape
+Sleep 2s
+
+Type "Create a container for new offers in my dataspace https://northwind-traders.demo.localhost/, add a 20% discount offer for three Beverages products, and add a chart of the offer prices."
+Sleep 1s
+Enter
+Sleep 240s
+
+Hide
+Type "/exit" Enter
+Sleep 3s
+Type "claude mcp remove web-algebra -s local" Enter
+Sleep 4s
diff --git a/screencast/undefined/front-1440-dark.png b/screencast/undefined/front-1440-dark.png
deleted file mode 100644
index b9c23063..00000000
Binary files a/screencast/undefined/front-1440-dark.png and /dev/null differ
diff --git a/screencast/undefined/front-1440-light.png b/screencast/undefined/front-1440-light.png
deleted file mode 100644
index 7f4a4980..00000000
Binary files a/screencast/undefined/front-1440-light.png and /dev/null differ
diff --git a/screencast/undefined/front-390-light.png b/screencast/undefined/front-390-light.png
deleted file mode 100644
index 4efe7f15..00000000
Binary files a/screencast/undefined/front-390-light.png and /dev/null differ