From 86af9896ae75ee0d3b193b93c863a74cb7ebf79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sat, 19 Sep 2026 00:54:04 +0200 Subject: [PATCH 01/13] Supercut of one-gesture UI shots, and the scenes that feed it A dataset-agnostic montage of LinkedDataHub gestures, each a single action with a visible result, cut from dedicated gesture takes rather than mined from the workflow footage. - render/supercut.mjs + render/supercut.json: the cutter. Each shot is the stretch between an -start and -end beat, led in and stopped short, capped from the front, pushed into the component the beat's focus box names (1.4x-2.4x), captioned, joined by hard cuts. The cut opens on the graph 3D bloom and closes on the LEGO colour pivot; separate tracks per stack, dark, 2x footage. - scenes/supercut-*.mjs: the gesture takes (Northwind graph, main gestures, RDFa link, the composed query->chart->embed->drag block, UNESCO tree, Rebrickable colour pivot, LTLOD layout modes). - lib/supercut.mjs, editing.mjs (block drag via the app's own DragEvents), and additions to constructors/graph/nav/harness/marks the scenes rely on. - Workflow scenes 02/06/08/09/11/13/14/16 extended to close the loop (read -> write -> re-read the same evidence); 15 retired. - Northwind and taxonomy ontology tweaks behind the scenes' constructors. - README/RULES/FINDINGS updated; the vector-mock line documented as retired. Co-Authored-By: Claude Opus 4.8 --- demo/northwind-traders/admin/model/ns.ttl | 1 + packages/editor/taxonomy/ns.ttl | 7 + screencast/FINDINGS.md | 36 +++ screencast/Makefile | 7 + screencast/README.md | 110 +++++++- screencast/RULES.md | 34 ++- screencast/lib/constructors.mjs | 78 +++++- screencast/lib/cursor.mjs | 4 + screencast/lib/editing.mjs | 159 ++++++++++++ screencast/lib/fixture.mjs | 9 + screencast/lib/graph.mjs | 33 ++- screencast/lib/harness.mjs | 7 + screencast/lib/marks.mjs | 5 +- screencast/lib/nav.mjs | 28 +- screencast/lib/supercut.mjs | 52 ++++ screencast/render/supercut.json | 31 +++ screencast/render/supercut.mjs | 244 ++++++++++++++++++ screencast/scenes/02-where-we-have-nobody.mjs | 78 +++++- screencast/scenes/06-worked-from-london.mjs | 60 ++++- .../scenes/08-late-customer-or-shipper.mjs | 50 +++- screencast/scenes/09-opening-houston.mjs | 2 +- screencast/scenes/11-a-new-order.mjs | 127 +++++++++ screencast/scenes/13-a-new-product.mjs | 117 +++++++++ screencast/scenes/14-reporting-lines.mjs | 120 +++++++++ .../16-a-territory-where-the-orders-are.mjs | 161 ++++++++++++ .../retired/15-a-rep-moves-to-seattle.mjs | 124 +++++++++ screencast/scenes/supercut-compose.mjs | 127 +++++++++ screencast/scenes/supercut-ltlod.mjs | 57 ++++ screencast/scenes/supercut-northwind-flow.mjs | 237 +++++++++++++++++ .../scenes/supercut-northwind-graph.mjs | 46 ++++ screencast/scenes/supercut-northwind.mjs | 141 ++++++++++ .../scenes/supercut-rebrickable-flow.mjs | 77 ++++++ .../scenes/supercut-rebrickable-pivot.mjs | 37 +++ screencast/scenes/supercut-rebrickable.mjs | 203 +++++++++++++++ screencast/scenes/supercut-unesco.mjs | 40 +++ 35 files changed, 2597 insertions(+), 52 deletions(-) create mode 100644 screencast/lib/editing.mjs create mode 100644 screencast/lib/supercut.mjs create mode 100644 screencast/render/supercut.json create mode 100644 screencast/render/supercut.mjs create mode 100644 screencast/scenes/11-a-new-order.mjs create mode 100644 screencast/scenes/13-a-new-product.mjs create mode 100644 screencast/scenes/14-reporting-lines.mjs create mode 100644 screencast/scenes/16-a-territory-where-the-orders-are.mjs create mode 100644 screencast/scenes/retired/15-a-rep-moves-to-seattle.mjs create mode 100644 screencast/scenes/supercut-compose.mjs create mode 100644 screencast/scenes/supercut-ltlod.mjs create mode 100644 screencast/scenes/supercut-northwind-flow.mjs create mode 100644 screencast/scenes/supercut-northwind-graph.mjs create mode 100644 screencast/scenes/supercut-northwind.mjs create mode 100644 screencast/scenes/supercut-rebrickable-flow.mjs create mode 100644 screencast/scenes/supercut-rebrickable-pivot.mjs create mode 100644 screencast/scenes/supercut-rebrickable.mjs create mode 100644 screencast/scenes/supercut-unesco.mjs diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl index 0dd03b63..7dc26383 100644 --- a/demo/northwind-traders/admin/model/ns.ttl +++ b/demo/northwind-traders/admin/model/ns.ttl @@ -268,6 +268,7 @@ schema:Order spin:constructor :OrderConstructor . dct:title [ a xsd:string ] ; schema:identifier [ a xsd:string ] ; schema:orderDate [ a xsd:date ] ; + schema:orderStatus schema:OrderProcessing ; schema:customer [ a schema:Corporation ] ; schema:broker [ a schema:Person ] ; schema:orderedItem [ a schema:OrderItem ] ; diff --git a/packages/editor/taxonomy/ns.ttl b/packages/editor/taxonomy/ns.ttl index 3279cefa..895543cb 100644 --- a/packages/editor/taxonomy/ns.ttl +++ b/packages/editor/taxonomy/ns.ttl @@ -24,6 +24,7 @@ skos:Concept spin:constructor :ConceptConstructor ; # xsd:string annotation next to one showing en. :ConceptConstructor a ldh:Constructor ; rdfs:label "Concept constructor" ; + dct:title "Concept constructor" ; sp:text """ PREFIX skos: PREFIX rdf: @@ -64,6 +65,7 @@ skos:narrower ldh:view :NarrowerConcepts . :SelectNarrowerConcepts a sp:Select ; rdfs:label "Select narrower concepts" ; + dct:title "Select narrower concepts" ; sp:text """ PREFIX skos: @@ -92,6 +94,7 @@ skos:broader ldh:view :BroaderConcepts . :SelectBroaderConcepts a sp:Select ; rdfs:label "Select broader concepts" ; + dct:title "Select broader concepts" ; sp:text """ PREFIX skos: @@ -116,6 +119,7 @@ skos:Collection spin:constructor :CollectionConstructor ; :CollectionConstructor a ldh:Constructor ; rdfs:label "Collection constructor" ; + dct:title "Collection constructor" ; sp:text """ PREFIX skos: PREFIX xsd: @@ -137,6 +141,7 @@ skos:member ldh:view :CollectionMembers . :SelectCollectionMembers a sp:Select ; rdfs:label "Select collection members" ; + dct:title "Select collection members" ; sp:text """ PREFIX skos: @@ -158,6 +163,7 @@ skos:ConceptScheme spin:constructor :ConceptSchemeConstructor . :ConceptSchemeConstructor a ldh:Constructor ; rdfs:label "Concept scheme constructor" ; + dct:title "Concept scheme constructor" ; sp:text """ PREFIX skos: PREFIX xsd: @@ -235,6 +241,7 @@ skos:inScheme ldh:inverseView :ConceptsInScheme , :OrphanConcepts . :SelectConceptsInScheme a sp:Select ; rdfs:label "Select concepts in scheme" ; + dct:title "Select concepts in scheme" ; sp:text """ PREFIX skos: diff --git a/screencast/FINDINGS.md b/screencast/FINDINGS.md index 14ac8236..01b503fb 100644 --- a/screencast/FINDINGS.md +++ b/screencast/FINDINGS.md @@ -370,6 +370,42 @@ the top; it reads the column instead. --- +## 13. The create modal's add-property row fails for a resource property + +**Where** the add-instance modal (`button.add-instance` on a derived view, e.g. Direct +reports on `/employees/2/` constructing a `schema:Person`), foot row `.ldh-prop-addrow`. + +**What happens** Selecting `areaServed` (a resource property, `rdfs:range schema:City`) +and pressing Add shows "The form could not be loaded — the page ran into an unexpected +error" and adds no row. Selecting `type` in the same modal adds a row. The same select and +Add on the **edit** form of an existing Person (`/employees/7/`) adds an `areaServed` row +with a lookup combobox as expected. + +**Expected** the create modal behaves like the edit form: one more lookup row. + +**Cost** a hire with four territories cannot be made in one form; scene 02 assigns one in +the modal and the other three on the new record's edit form. + +--- + +## 14. Create-from-view ends in two different places, and the view it came from does not refresh + +**Where** `button.add-instance` on derived views: Cities in this region (`schema:City`), +Products in this category (`schema:Product`), Orders from this customer (`schema:Order`). + +**What happens** Saving a City navigates to the new document. Saving a Product or an Order +closes the modal and leaves the page where it was — with the view still showing the old +count ("Total results 12" after the thirteenth product was created); the new row appears +only after the page is loaded again. + +**Expected** one behaviour for all constructors, and the view that offered the Create +button re-reading itself after a successful save — that is the loop the button exists for. + +**Cost** scenes 11 and 13 re-read their list by clicking the page's own title link; the +rig waits for either the navigation or the modal closing (`createFromView`). + +--- + ## Not reproduced / not attributed - **`Terminated with [object DocumentFragment]`** appears twice on essentially every diff --git a/screencast/Makefile b/screencast/Makefile index 7050761c..a1837dd8 100644 --- a/screencast/Makefile +++ b/screencast/Makefile @@ -145,6 +145,13 @@ docs-publish: printf "%s %s\n" "$$(sha1sum "$$dest" | cut -d' ' -f1)" "$${dest#$(DOCS_DEST)/}"; \ done +# The supercut: one-gesture shots cut from the gesture takes (scenes/supercut-*.mjs) +# between their -start / -end beats, captioned and joined. SUPERCUT names the +# shot list. +SUPERCUT ?= render/supercut.json +supercut: + node render/supercut.mjs $(SUPERCUT) + mp4: @shopt -s nullglob; \ for w in tracks/*.webm; do \ diff --git a/screencast/README.md b/screencast/README.md index ed007de1..978abe96 100644 --- a/screencast/README.md +++ b/screencast/README.md @@ -19,13 +19,17 @@ The five scenes, in the order they answer best: | Scene | opens on | question | |---|---|---| -| `02-where-we-have-nobody` | `/territories/`, 53 pins | which territories have no sales rep? | +| `02-where-we-have-nobody` | `/territories/`, 53 pins | which territories have no sales rep? — then a hire covers them, and the map is read again | | `04-is-our-beverages-theirs` | `/categories/`, eight photographs | does a published vocabulary mean what we mean? | -| `06-worked-from-london` | `/employees/`, nine faces | which US territories are worked from London? | +| `06-worked-from-london` | `/employees/`, nine faces | which US territories are worked from London? — then one is handed to Seattle | | `07-one-rep-for-the-south` | `/territories/`, 53 pins | how thinly is each region staffed? | -| `08-late-customer-or-shipper` | a late order in Graph mode | do late orders follow the customer or the carrier? | +| `08-late-customer-or-shipper` | a late order in Graph mode | do late orders follow the customer or the carrier? — then the order is marked delivered and the chart re-read | | `09-opening-houston` | `/territories/`, 53 pins | a new territory, created from the region's own list | | `10-the-dearest-thing` | `/categories/`, eight photographs | the dearest product's bare page, documented from outside | +| `11-a-new-order` | a customer's page, their orders listed | an order booked from the customer's own list; the list read again | +| `13-a-new-product` | `/categories/`, eight photographs | a product created from the category's own list; the list read again | +| `14-reporting-lines` | `/employees/`, nine faces | one reporting link re-pointed; both managers' lists read again | +| `16-a-territory-where-the-orders-are` | `/territories/`, 53 pins | US deliveries with no territory, charted; a territory opened where most land; the chart read again | Each of those five writes a block saying *why* the opening state was on screen and what it could not answer — a strong first frame the rest of the clip never refers back to is @@ -173,3 +177,103 @@ that every baked hash resolves. locator can drive a tab nobody is looking at — see `lib/dom.mjs`. `FINDINGS.md` records the product defects these scenes ran into. + +## Supercut + +`make supercut` cuts `tracks/supercut.mp4` (every shot) and `tracks/supercut-spine.mp4` +(the shots flagged `spine`) from the gesture takes, following `render/supercut.json`: each +shot is the stretch of a track between its `-start` and `-end` beats, led in by +0.3 s and stopped 0.1 s short (a beat and its gesture are the same instant), capped from +the front at `maxSeconds` (shifted by `fromOffset` / `toOffset` seconds — a negative +`toOffset` ends a shot before a helper's dead settle), framed on the component the beat's +`focus` box names — `move: "push"` closes in from the full frame over the whole shot, +`"static"` holds the box's frame throughout, `"pan"` moves from the start beat's box to the +end beat's over `moveSeconds` (1.4 s by default — a move between blocks is gradual, never +a jump) at `moveAt` (negative counts back from the gesture's end, where the new block has +just appeared); zoom between `minZoom` (1.4) and `maxZoom` (2.4) +per shot, anchored on the box's top part; every beat carries the drawn cursor's position +(`pointer`, added by the harness), which shapes the frame only for a shot with +`keepPointer: true` (unioning it into every frame flattened the pans, so it is off by +default) — held on its last frame, +captioned (rendered by Playwright, overlaid), and joined by hard cuts through the concat +demuxer — one decoder at a time; a single xfade graph over every input was killed for +memory twice. + +The cut is now **one connected flow on Northwind**, `scenes/supercut-northwind-flow.mjs`: +one recording, and after the opening load no page is reached by a goto. The Employees +view opens in Graph mode (its `ac:mode` is patched to `ac:GraphMode` off camera and put +back afterwards); an employee's node blooms with its own document; the Territory pill +pivots the graph to the 53 territories and the Region pill to the four regions; the +Southern node's link opens the region's page. Everything else happens on that page: a +city through the form and its pin on the region's own map (the view re-reads itself after +Save); Create ▸ SELECT, the query typed and saved; a bar chart from it; Content mode from +the document's mode toggle; a sentence; the chart embedded as an Object; the sentence +dragged below it. The whole take is 2× footage, graph included: `lib/graph.mjs` scales +the canvas-local points `graph2ScreenCoords` returns by the document's CSS zoom (the +canvas rect is in zoomed viewport pixels, the graph's own coordinates are not), so the +earlier zoom-1 exception for graph takes is gone. Southern's blocks and the +previous take's query and chart are cleared off camera first. Beats mark 17 shots; the +cut list picks the stretches, so what the viewer does not see (the form being filled at +3×, the query typed at 2×) is compressed, never cut to another page. + +Earlier takes stay in `scenes/` for the record: `supercut-northwind-graph.mjs` (the order +graph), `supercut-northwind.mjs` (gesture shots 4, 9, 15, 12, 8), `supercut-compose.mjs` +(the compose sequence on its own page), `supercut-unesco.mjs`, `supercut-ltlod.mjs`, and +the Rebrickable pair `supercut-rebrickable.mjs` / `supercut-rebrickable-flow.mjs` — +Rebrickable was dropped from the cut: its stack runs `atomgraph/linkeddatahub:5.6.0`, +which has no dark theme, so its shots came out light in a dark cut. + +The takes are 2× footage: a 2880×1800 viewport with the document zoomed 200 % +(`lib/supercut.mjs`), since the video is the viewport in CSS pixels. `FLAGS=--shots=6,9` +records a subset of a gesture take; **rename the take to its own track straight after** +(`supercut-rebrickable-b`), then point the JSON's shots at it — a second `make scene` of +the same file overwrites `tracks/.webm`, which is how one good take was lost. + +Stacks: `linkeddatahub.com` serves Northwind and UNESCO; Rebrickable is `:4444`; LTLOD is +`:4443`. Each stack's nginx also binds a client-certificate port, and the defaults collide +— LTLOD's 5443 with `linkeddatahub.com`, Rebrickable's 5444 with the LinkedDataHub dev +stack — so start them with a free one: `HTTPS_CLIENT_CERT_PORT=5445 docker compose up -d` +in `../LTLOD`, `HTTPS_CLIENT_CERT_PORT=5446 docker compose up -d nginx` in `../Rebrickable` +(the port is only nginx's; the rest of the stack is unaffected). `docker compose stop` / +`docker compose start` put everything back. + +A page scroll on camera is `easeScrollTo` (`lib/supercut.mjs`): the page glides to the +element over ~1.4 s on an ease-in-out, so a static crop reads as a slow pan. The +browser's own smooth scroll is a 300 ms lurch, and a pan in the cutter over a page that +jumps underneath cannot be smooth either. + +Drag-and-drop on camera is the app's own drag events (`dragBlock` in `lib/editing.mjs`): +Playwright's pointer drags and `dragTo` never reach the handler, a dispatched `DragEvent` +whose `DataTransfer` carries `application/vnd.atomgraph.linkeddatahub.block` does, and the +pointer overlay travels alongside. The app moves the dragged block **after** the drop +target (`client/block.xsl:612`), so a drop on the previous sibling is a no-op — to put the +chart above the sentence, the sentence is dragged onto the chart. + +Not in the cut, and why: + +- **File drop** — the content body accepts only the app's block type (`client/block.xsl:698`); + the set's edit form on Rebrickable offers no file input either (`foaf:depiction` is a URL). +- **Map popup on LTLOD** — the pixel-scan pin click did not open a popup at 2×. +- **Search on Rebrickable** — the dialog answered "the endpoint could not be reached" on + a 158 k-document search; the lookup-by-title combobox also times out there, so the + grid embed pastes a copied URI instead. +- **Tree expand on UNESCO** — the branch did not open (4 rows before and after). +- **Versioning, ACL, import wizard** — not shot: no history on these stacks; the other two + were not designed. + +## Vector mock (retired) + +Retired 2026-09-18: too much of every shot was imitation and the result read as fake beside +the recorded cut. Left in place, not extended. The shots were not screen recordings. Each shot is a page under `mock/` +(`form-pin.html`, `bloom.html`, `shapes.html`) that composes the app's **own components** — +markup and stylesheets captured from the running dataspace (`scenes/capture-states.mjs` +writes `mock/states/app.css` with every `@import` and `url()` inlined, plus the modal, the +view block head, the results in each mode, the graph chrome as `mock/templates/*.html`) — +around **made-up data**, and animates between states on a `window.render(t)` timeline. +`render/mock.mjs` serves the page, steps `t` frame by frame, screenshots at 2× and encodes: +crisp at any zoom, no cursor, no chrome the shot does not want, and the timing is the +shot's, not the app's. Maps, graphs and charts are drawn (SVG over a captured basemap; +d3-force for layout, in the app's hue-per-type idiom) because their canvases do not +serialise. The contract: every **state** shown is one the product produces; the **motion** +between states is invented; the **data** is whatever reads best. `render/mock-cut.json` + +`make supercut SUPERCUT=render/mock-cut.json` joins the shots. diff --git a/screencast/RULES.md b/screencast/RULES.md index 26639926..28a64a0d 100644 --- a/screencast/RULES.md +++ b/screencast/RULES.md @@ -144,7 +144,16 @@ same map is read at the close (54). The write is not the point; the second read shows the app answering to what was just put into it. A scene that reads, then writes a query or a chart, then reads *that*, has added a lens; the data it opened on is unchanged, and the clip is thinner for it. So: open on evidence, write into the data, and end on the -evidence re-read through the same view, query or chart. Scored as **C** in the rubric. +evidence re-read through the same view, query or chart. Scored as **C** in the rubric. + +**W is the multiplier, and it has to be used as one.** A scene that changes the data is +not thereby realistic: "A rep moves to Seattle" moved a pin by typing latitude and +longitude into a person's record — a loop (C 10) along a real route (R 9) that nobody +would ever walk — and it scored 74 because W sat at 8, where every scene's W sat. +The scale: **10** a job someone does this week, in exactly this way; **8** a real job, +done a plausible way; **6** a real question, but a workflow a demo invents; **4** the +data changes, but no one would make it change like that; **2** staged. Below 6 the +scene is not shot, whatever the other columns say. **Returning from a proxied dataspace is a tab switch, not browser back** — and a tab switch does not change the URL. @@ -408,3 +417,26 @@ exists. **Verify on the running instance**, not by reading the code — computed styles and measured boxes, never "it should". + +**The cursor and the control it clicks are visible — by scene design, not by the +crop.** A crop that frames the block a result appears in while the click lands outside +it shows an effect with no cause. The fix is in the scene: click the control nearest the +result (the form's own Save, the view's own Create, the pill in the bar above the +graph), let the drawn cursor travel inside the framed block, and give the beat a focus +box that covers both the control and the result. Widening the crop to contain every +click was tried and reverted — it pushed every shot out to the full frame and flattened +the pans. And the camera **pans to the action control** before it is clicked: the scene marks a +beat as the pointer arrives on the control (its focus box is the control), and the +shot lists that beat in `via`, so the crop glides block → control → result on smooth +moves (`cropPath` in the cutter) rather than widening. Every beat still records +`pointer`; the cutter uses it only when a shot sets `keepPointer: true`. On camera a +control is clicked with the drawn cursor +(`cursor.click`, or `cursor.moveTo` then the click), never a bare `locator.click()`. + +**A camera move between blocks is gradual.** When a supercut shot pans from one block +to another — the query to the chart made from it, the XHTML block to the Object block, +any block to any other — the crop travels on a smooth curve over about 1.4 s +(`move: "pan"`, `moveSeconds` in `render/supercut.json`), timed to the new block's +arrival, never a jump. The same for a scroll on camera: `behavior: 'smooth'`, never an +instant `scrollTo`. A jump inside one page reads as an edit; a slow pan reads as the +viewer's own eye moving down the page. diff --git a/screencast/lib/constructors.mjs b/screencast/lib/constructors.mjs index 138d5d66..15aafdef 100644 --- a/screencast/lib/constructors.mjs +++ b/screencast/lib/constructors.mjs @@ -9,6 +9,8 @@ // resource being created belongs to this document. import { ui, settled } from './dom.mjs'; +import { pickByLabel } from './blocks.mjs'; +import { addValue } from './editing.mjs'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -40,7 +42,11 @@ export async function create(page, cursor, label) { // CodeMirror types over the ones its auto-closing inserted. // // All of that is silent when it goes wrong, so the text is read back and compared. -export async function typeQuery(page, cursor, query) { +// `onAttempt(n)` fires as each typing attempt starts — a scene re-marks its shot's start +// there, so a retake after a failed read-back is what the cut shows, not the failure. +// `onTyped()` fires the moment the last character is in, before the settle and the +// read-back — the beat that ends a typing shot. +export async function typeQuery(page, cursor, query, { onAttempt = null, onTyped = null } = {}) { const code = ui(page).locator('.CodeMirror, .yasqe').first(); if (!(await code.isVisible().catch(() => false))) return { ok: false, why: 'no query editor on the form' }; @@ -61,6 +67,7 @@ export async function typeQuery(page, cursor, query) { await page.keyboard.press('Enter'); } } + if (onTyped) await onTyped(); await sleep(1200); }; const readBack = () => page.evaluate(() => { @@ -70,6 +77,7 @@ export async function typeQuery(page, cursor, query) { // Compare ignoring the indentation the editor added for us. const flat = (t) => t.split('\n').map((l) => l.trim()).filter(Boolean).join('\n'); + if (onAttempt) await onAttempt(1); await typeLines(14); let written = await readBack(); if (written === null) return { ok: true, lines: query.split('\n').length, verified: false }; @@ -78,6 +86,7 @@ export async function typeQuery(page, cursor, query) { // than a retake on camera. const mod = process.platform === 'darwin' ? 'Meta' : 'Control'; await page.keyboard.press(`${mod}+A`); await page.keyboard.press('Backspace'); await sleep(300); + if (onAttempt) await onAttempt(2); await typeLines(28); written = await readBack(); } @@ -92,10 +101,17 @@ export async function typeQuery(page, cursor, query) { // The control belonging to a named property. Everything that writes to a form goes // through here — see the form-fields-by-name rule. -export function field(page, label, kind = 'input:not([type=hidden]):visible, textarea:visible, select:visible') { - return ui(page).locator('.ldh-prop-group').filter({ hasText: label }).first().locator(kind).first(); +// `scope` narrows the search to one form. Without it the first `.ldh-prop-group` +// named Title on a record page is the record's own read-only Title row, and the +// form's field is never reached — the query form on the Southern page sat unfilled +// for a minute that way. +export function field(page, label, kind = 'input:not([type=hidden]):visible, textarea:visible, select:visible', scope = null) { + return (scope ?? ui(page)).locator('.ldh-prop-group').filter({ hasText: label }).first().locator(kind).first(); } +// The form being edited: the last visible form that carries a Save button. +export const openForm = (page) => ui(page).locator('form').filter({ has: page.locator('button.btn-save, button[class*="btn-save"]') }).filter({ visible: true }).last(); + // Fills a field by its property name. // // The form has no