diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl index ba43e716..1cb72a95 100644 --- a/demo/northwind-traders/admin/model/ns.ttl +++ b/demo/northwind-traders/admin/model/ns.ttl @@ -284,6 +284,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 a79469ca..11ee744e 100644 --- a/packages/editor/taxonomy/ns.ttl +++ b/packages/editor/taxonomy/ns.ttl @@ -26,6 +26,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: @@ -69,6 +70,7 @@ skos:narrower ldh:view :NarrowerConcepts . :SelectNarrowerConcepts a sp:Select ; rdfs:label "Select narrower concepts" ; + dct:title "Select narrower concepts" ; sp:text """ PREFIX skos: @@ -99,6 +101,7 @@ skos:broader ldh:view :BroaderConcepts . :SelectBroaderConcepts a sp:Select ; rdfs:label "Select broader concepts" ; + dct:title "Select broader concepts" ; sp:text """ PREFIX skos: @@ -123,6 +126,7 @@ skos:Collection spin:constructor :CollectionConstructor ; :CollectionConstructor a ldh:Constructor ; rdfs:label "Collection constructor" ; + dct:title "Collection constructor" ; sp:text """ PREFIX skos: PREFIX rdf: @@ -150,6 +154,7 @@ skos:member ldh:view :CollectionMembers . :SelectCollectionMembers a sp:Select ; rdfs:label "Select collection members" ; + dct:title "Select collection members" ; sp:text """ PREFIX skos: @@ -171,6 +176,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 rdf: @@ -254,6 +260,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/.gitignore b/screencast/.gitignore index ff327582..b0ddc6be 100644 --- a/screencast/.gitignore +++ b/screencast/.gitignore @@ -2,3 +2,8 @@ node_modules/ tracks/ shots/ docs/out/ +# the vector mock's captures: written by scenes/capture-*.mjs from the running dataspace +mock/states/ +mock/templates/ +mock/assets/*.png +mock/assets/*.json 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 5831666e..f182a77d 100644 --- a/screencast/Makefile +++ b/screencast/Makefile @@ -147,6 +147,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 9feacf86..8f1e07b3 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 @@ -48,7 +52,10 @@ make docs-publish # optimise docs/out/ into ../docs/ ``` Nothing is hardcoded: origin, keystore, password and the `ldh` binary are all -parameters, with the Makefile carrying overridable defaults. +parameters, with the Makefile carrying overridable defaults. The off-camera fixture +resets shell out to `ldh`, so it must be on PATH (`../../LinkedDataHub/cli/bin`) or named +by `LDH_BIN`; a scene whose reset fails stops before the browser opens rather than +filming last take's leftovers. ## Output @@ -66,6 +73,23 @@ forward and nothing is shown out of order. Where to cut is measured, not chosen a scene fires its first beat the instant its opening view has painted, and the trim lands 0.3s before it. +`render/stitch.mjs` turns a scroll a take made into ground the camera can cross, for a +take that jumped: a browser's own scroll is a 300 ms glide, but its frames are one page +at successive offsets, so the script measures each frame's shift, lays the rows into one +tall still (`tracks/.scroll.png`, fixed chrome dropped) and ends it on the frame +the cut resumes from; the cutter reads a `.png` track as a still whose `from`/`to` span +the pan in seconds. The compose take no longer needs it: the scene glides the form up +with `easeScrollTo` before the Title click and `easeScrollTop` before the mode switch and +the drag, so the camera rides the page's own scroll from the query editor down to Save. + +Shots that continue one take abut exactly: a shot's lead (`leadSeconds`) and trail +(`trailSeconds`) overlap its neighbour by 0.2 s, so a continuing shot carries +`fromOffset: 0.2` and the join lands on the frame the previous shot ended on. A shot's +`startFocus` or `focus` box may carry its own `minZoom`/`maxZoom`/`pad`, so a shot can +start on the tight frame its predecessor held and land on a wider one. Every framing +runs on zoompan now — the crop filter's size is fixed for a stream, so it could slide +but never zoom, and its rounding differed from zoompan's by a pixel at a join. + `render/review.mjs` answers the three questions worth asking: did every beat happen (an aborted run does not report green), did the page end up rich, and does anything embed an `ldh:Object` inside another one. `--sheet` builds a contact sheet from the @@ -197,3 +221,107 @@ 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, then the customer record's Related results hopped to the reps and on to their +territories, Rockville opened, a rep hired for it on the Person form its view constructs, +the grid re-read one face richer, the breadcrumb up to Sales territories — one take, so +the cut moves the camera between those beats and never cuts; recorded with +`SCHEME=dark`, like every take in the dark cut), `supercut-northwind.mjs` (gesture shots 4, 9, 15, 12, 8), `supercut-compose.mjs` +(the compose sequence on its own page — one take from the query to the drag, cut as one continuous camera: every shot continues the previous one, the long gestures at 2–3×), `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 3c7e499c..2dd62d76 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/fixtures/dana.jpg b/screencast/fixtures/dana.jpg new file mode 100644 index 00000000..90faeb49 Binary files /dev/null and b/screencast/fixtures/dana.jpg differ diff --git a/screencast/lib/blocks.mjs b/screencast/lib/blocks.mjs index b92efb84..09ee8a8d 100644 --- a/screencast/lib/blocks.mjs +++ b/screencast/lib/blocks.mjs @@ -293,3 +293,24 @@ export async function switchDocumentMode(page, cursor, mode = 'content-mode', { } return false; } + +// A block's control bands wait to be asked for. Since 2026-09-24 a view block renders its +// facet, sort and mode toolbar and its Related results row collapsed, and a chart block its +// three selects, until the toggle in the card header is pressed - one gesture, one toggle +// (`button.tb-controls`, aria-pressed). A scene that goes for a pill or a select without +// pressing it first is reaching for something the reader cannot see, so this is on camera: +// the pointer travels to the toggle and clicks, and the bands are awaited before the scene +// goes on. Idempotent: a toggle already pressed is left alone. +export async function revealControls(page, cursor, block, { settle = 700 } = {}) { + const toggle = block.locator('.ldh-block-head button.tb-controls').first(); + // a query block draws its chart selects open and keeps only its editor behind a toggle + // (tb-query), so a head without tb-controls is a block with nothing to reveal + if (!(await toggle.count())) return { ok: true, already: true, why: 'no controls toggle: the block draws its controls open' }; + if ((await toggle.getAttribute('aria-pressed')) === 'true') return { ok: true, already: true }; + await toggle.scrollIntoViewIfNeeded(); + await cursor.click(toggle); + const bands = block.locator('.ldh-view-toolbar:not(.is-collapsed), .chart-controls:not(.is-collapsed)').first(); + await bands.waitFor({ state: 'visible', timeout: 8000 }).catch(() => {}); + await sleep(settle); + return { ok: (await toggle.getAttribute('aria-pressed')) === 'true' }; +} diff --git a/screencast/lib/constructors.mjs b/screencast/lib/constructors.mjs index 5a638948..26258b38 100644 --- a/screencast/lib/constructors.mjs +++ b/screencast/lib/constructors.mjs @@ -14,6 +14,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)); @@ -80,7 +82,11 @@ export async function createFromDock(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' }; @@ -101,6 +107,7 @@ export async function typeQuery(page, cursor, query) { await page.keyboard.press('Enter'); } } + if (onTyped) await onTyped(); await sleep(1200); }; const readBack = () => page.evaluate(() => { @@ -110,6 +117,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 }; @@ -118,6 +126,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(); } @@ -132,10 +141,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