diff --git a/.github/workflows/demos.yml b/.github/workflows/demos.yml new file mode 100644 index 0000000..7ab8e38 --- /dev/null +++ b/.github/workflows/demos.yml @@ -0,0 +1,30 @@ +name: Generate interaction demos + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + record: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: 'npm' + - run: npm ci + - run: npx playwright install --with-deps chromium + - name: Install VP9 encoder + run: sudo apt-get update && sudo apt-get install -y ffmpeg + - name: Record and verify demos + run: npm run demos + - uses: actions/upload-artifact@v7 + if: always() + with: + name: varri-interaction-demos + path: output/playwright/ + retention-days: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e299833..8eba680 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,7 @@ jobs: run: | npm run test:architecture npm run test:ci + npm run demos:check-published - name: Verify installed package contents run: npm run test:package @@ -45,6 +46,9 @@ jobs: npm run test:pages npm run test:visual + - name: Record and verify interaction demos + run: npm run demos + - name: Upload browser diagnostics if: failure() uses: actions/upload-artifact@v7 diff --git a/README.html b/README.html index 58aa491..8de1a6c 100644 --- a/README.html +++ b/README.html @@ -5,6 +5,7 @@ vaRRI README + diff --git a/README.md b/README.md index 5069991..280c7dd 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,16 @@ the classic bundle includes D3, and neither entry requires Vue or Fornac scripts The `src` directory provides a [detailed vaRRI Library API documentation](src/README.md) +## Recording short interface demos + +In a source checkout, run `npm run demos` to create the 12 short WebM demonstrations +used by the viewer guide and canvas interaction help. They cover input, selection, +movement, rotation, annotations, position controls, sessions, and PNG export. Each clip +has a poster, captions, a written transcript, and a preview-gallery entry. The +[demo pipeline guide](https://github.com/BackofenLab/vaRRI/blob/main/recording/README.md) +explains recording, review-gated publishing, size controls, adding scenes, and the +manual GitHub Actions workflow. + --- diff --git a/doc/demos/add-point-mutation.png b/doc/demos/add-point-mutation.png new file mode 100644 index 0000000..1275ede Binary files /dev/null and b/doc/demos/add-point-mutation.png differ diff --git a/doc/demos/add-point-mutation.vtt b/doc/demos/add-point-mutation.vtt new file mode 100644 index 0000000..f7c0427 --- /dev/null +++ b/doc/demos/add-point-mutation.vtt @@ -0,0 +1,25 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:05.467 +Click Add in Point Mutations to annotate the supplied structure + +2 +00:00:05.467 --> 00:00:10.200 +Choose Seq. 2 for the second RNA strand + +3 +00:00:10.200 --> 00:00:14.933 +Enter biological position 102 + +4 +00:00:14.933 --> 00:00:20.200 +Enter A in To for the replacement annotation + +5 +00:00:20.200 --> 00:00:26.267 +Click OK to add the G102A annotation + +6 +00:00:26.267 --> 00:00:35.800 +G102A adds a green outline and A label; inputs stay unchanged and no fold is predicted diff --git a/doc/demos/add-point-mutation.webm b/doc/demos/add-point-mutation.webm new file mode 100644 index 0000000..a239597 Binary files /dev/null and b/doc/demos/add-point-mutation.webm differ diff --git a/doc/demos/add-region-highlight.png b/doc/demos/add-region-highlight.png new file mode 100644 index 0000000..a8e80b0 Binary files /dev/null and b/doc/demos/add-region-highlight.png differ diff --git a/doc/demos/add-region-highlight.vtt b/doc/demos/add-region-highlight.vtt new file mode 100644 index 0000000..c9dedfc --- /dev/null +++ b/doc/demos/add-region-highlight.vtt @@ -0,0 +1,25 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.267 +Click Add in Region Highlights + +2 +00:00:04.267 --> 00:00:09.267 +Enter 1-2 for the first RNA strand + +3 +00:00:09.267 --> 00:00:14.867 +Enter 3-4 for its partner on the second RNA + +4 +00:00:14.867 --> 00:00:19.600 +Set opacity to 0.5 for a translucent highlight + +5 +00:00:19.600 --> 00:00:26.267 +Click OK to add the highlight to the drawing + +6 +00:00:26.267 --> 00:00:32.933 +The paired region is highlighted and listed on the left diff --git a/doc/demos/add-region-highlight.webm b/doc/demos/add-region-highlight.webm new file mode 100644 index 0000000..6310ae1 Binary files /dev/null and b/doc/demos/add-region-highlight.webm differ diff --git a/doc/demos/add-subsequence-highlight.png b/doc/demos/add-subsequence-highlight.png new file mode 100644 index 0000000..1d057c7 Binary files /dev/null and b/doc/demos/add-subsequence-highlight.png differ diff --git a/doc/demos/add-subsequence-highlight.vtt b/doc/demos/add-subsequence-highlight.vtt new file mode 100644 index 0000000..a36880c --- /dev/null +++ b/doc/demos/add-subsequence-highlight.vtt @@ -0,0 +1,25 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.267 +Click Add in Subsequence Highlights + +2 +00:00:04.267 --> 00:00:09.000 +Choose Seq. 2 for the second RNA strand + +3 +00:00:09.000 --> 00:00:13.733 +Enter biological range 102-103 + +4 +00:00:13.733 --> 00:00:17.933 +Set opacity to 0.5 + +5 +00:00:17.933 --> 00:00:23.733 +Click OK to add the highlight + +6 +00:00:23.733 --> 00:00:30.467 +The purple range is highlighted and listed diff --git a/doc/demos/add-subsequence-highlight.webm b/doc/demos/add-subsequence-highlight.webm new file mode 100644 index 0000000..be3c439 Binary files /dev/null and b/doc/demos/add-subsequence-highlight.webm differ diff --git a/doc/demos/apply-probability-profile.png b/doc/demos/apply-probability-profile.png new file mode 100644 index 0000000..6a8123e Binary files /dev/null and b/doc/demos/apply-probability-profile.png differ diff --git a/doc/demos/apply-probability-profile.vtt b/doc/demos/apply-probability-profile.vtt new file mode 100644 index 0000000..bddb5e5 --- /dev/null +++ b/doc/demos/apply-probability-profile.vtt @@ -0,0 +1,21 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:06.067 +Enter each nucleotide index and its probability for RNA 1. + +2 +00:00:06.067 --> 00:00:11.733 +Choose Start 1: indices are -2, -1, 1, 2, skipping zero. + +3 +00:00:11.733 --> 00:00:17.467 +Uncheck =1 so purple represents zero; higher values fade the overlay. + +4 +00:00:17.467 --> 00:00:24.667 +Click Apply to color RNA 1 using the probability profile. + +5 +00:00:24.667 --> 00:00:30.600 +Lower probabilities have stronger purple shading on RNA 1. diff --git a/doc/demos/apply-probability-profile.webm b/doc/demos/apply-probability-profile.webm new file mode 100644 index 0000000..963424a Binary files /dev/null and b/doc/demos/apply-probability-profile.webm differ diff --git a/doc/demos/choose-example.png b/doc/demos/choose-example.png new file mode 100644 index 0000000..d314bfb Binary files /dev/null and b/doc/demos/choose-example.png differ diff --git a/doc/demos/choose-example.vtt b/doc/demos/choose-example.vtt new file mode 100644 index 0000000..71ff673 --- /dev/null +++ b/doc/demos/choose-example.vtt @@ -0,0 +1,13 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.533 +Open Example to browse the built-in examples + +2 +00:00:04.533 --> 00:00:10.867 +Choose Mutations & highlighting + +3 +00:00:10.867 --> 00:00:18.800 +The inputs and figure include four mutations and a green highlight diff --git a/doc/demos/choose-example.webm b/doc/demos/choose-example.webm new file mode 100644 index 0000000..bf034d1 Binary files /dev/null and b/doc/demos/choose-example.webm differ diff --git a/doc/demos/export-png.png b/doc/demos/export-png.png new file mode 100644 index 0000000..ec51244 Binary files /dev/null and b/doc/demos/export-png.png differ diff --git a/doc/demos/export-png.vtt b/doc/demos/export-png.vtt new file mode 100644 index 0000000..8861720 --- /dev/null +++ b/doc/demos/export-png.vtt @@ -0,0 +1,21 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:05.133 +Click PNG to choose image size and print density + +2 +00:00:05.133 --> 00:00:11.867 +Set width to 1200 pixels; height follows the aspect ratio + +3 +00:00:11.867 --> 00:00:18.600 +Set DPI to 300; the pixel dimensions stay the same + +4 +00:00:18.600 --> 00:00:23.667 +Click Export PNG to download the image + +5 +00:00:23.667 --> 00:00:29.533 +PNG downloaded: 1200 × 1008 pixels at 300 DPI diff --git a/doc/demos/export-png.webm b/doc/demos/export-png.webm new file mode 100644 index 0000000..d147bbe Binary files /dev/null and b/doc/demos/export-png.webm differ diff --git a/doc/demos/highlight-rri-background.png b/doc/demos/highlight-rri-background.png new file mode 100644 index 0000000..55a06b0 Binary files /dev/null and b/doc/demos/highlight-rri-background.png differ diff --git a/doc/demos/highlight-rri-background.vtt b/doc/demos/highlight-rri-background.vtt new file mode 100644 index 0000000..dd143f7 --- /dev/null +++ b/doc/demos/highlight-rri-background.vtt @@ -0,0 +1,25 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:07.667 +Choose region: this dropdown controls automatic RRI backgrounds + +2 +00:00:07.667 --> 00:00:11.867 +Region adds one generated highlight: 1-5&1-5 + +3 +00:00:11.867 --> 00:00:19.000 +Choose basepairs to separate the two paired stacks + +4 +00:00:19.000 --> 00:00:24.067 +Two generated highlights leave the unpaired A bases clear + +5 +00:00:24.067 --> 00:00:32.000 +Choose nothing to remove the automatic highlights + +6 +00:00:32.000 --> 00:00:39.467 +The generated entries and backgrounds are gone; the input is unchanged diff --git a/doc/demos/highlight-rri-background.webm b/doc/demos/highlight-rri-background.webm new file mode 100644 index 0000000..87ac8fc Binary files /dev/null and b/doc/demos/highlight-rri-background.webm differ diff --git a/doc/demos/highlight-rri-nucleotides.png b/doc/demos/highlight-rri-nucleotides.png new file mode 100644 index 0000000..0475907 Binary files /dev/null and b/doc/demos/highlight-rri-nucleotides.png differ diff --git a/doc/demos/highlight-rri-nucleotides.vtt b/doc/demos/highlight-rri-nucleotides.vtt new file mode 100644 index 0000000..13e57ee --- /dev/null +++ b/doc/demos/highlight-rri-nucleotides.vtt @@ -0,0 +1,17 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:06.867 +Choose region for RRI nucleotide highlighting + +2 +00:00:06.867 --> 00:00:13.733 +Region outlines all 10 nucleotides, including both unpaired A bases + +3 +00:00:13.733 --> 00:00:21.400 +Choose basepairs to outline only paired nucleotides + +4 +00:00:21.400 --> 00:00:30.667 +Basepairs outlines 8 nucleotides; both unpaired A bases lose the red outline diff --git a/doc/demos/highlight-rri-nucleotides.webm b/doc/demos/highlight-rri-nucleotides.webm new file mode 100644 index 0000000..764533c Binary files /dev/null and b/doc/demos/highlight-rri-nucleotides.webm differ diff --git a/doc/demos/import-fasta.png b/doc/demos/import-fasta.png new file mode 100644 index 0000000..a09484c Binary files /dev/null and b/doc/demos/import-fasta.png differ diff --git a/doc/demos/import-fasta.vtt b/doc/demos/import-fasta.vtt new file mode 100644 index 0000000..188f483 --- /dev/null +++ b/doc/demos/import-fasta.vtt @@ -0,0 +1,17 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.533 +Open FASTA Data to import RNA records + +2 +00:00:04.533 --> 00:00:09.933 +Paste two FASTA records with sequences and structures + +3 +00:00:09.933 --> 00:00:15.600 +Check the parsed sequences, structures, and names + +4 +00:00:15.600 --> 00:00:24.000 +Click OK to render the imported RNA diff --git a/doc/demos/import-fasta.webm b/doc/demos/import-fasta.webm new file mode 100644 index 0000000..a791a1a Binary files /dev/null and b/doc/demos/import-fasta.webm differ diff --git a/doc/demos/linearize-rri-layout.png b/doc/demos/linearize-rri-layout.png new file mode 100644 index 0000000..5f12be7 Binary files /dev/null and b/doc/demos/linearize-rri-layout.png differ diff --git a/doc/demos/linearize-rri-layout.vtt b/doc/demos/linearize-rri-layout.vtt new file mode 100644 index 0000000..69ecb0b --- /dev/null +++ b/doc/demos/linearize-rri-layout.vtt @@ -0,0 +1,9 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:08.667 +With Force layout off, enable Linear horizontal RRI layout + +2 +00:00:08.667 --> 00:00:17.333 +Paired bases now form horizontal rails; Force layout stays off diff --git a/doc/demos/linearize-rri-layout.webm b/doc/demos/linearize-rri-layout.webm new file mode 100644 index 0000000..4a5126b Binary files /dev/null and b/doc/demos/linearize-rri-layout.webm differ diff --git a/doc/demos/manifest.json b/doc/demos/manifest.json new file mode 100644 index 0000000..5e92cad --- /dev/null +++ b/doc/demos/manifest.json @@ -0,0 +1 @@ +{"schemaVersion":1,"sourceRevision":"65ec7f2b0b0115855a91bfbb27360f88320262ef","generatedAt":"2026-10-31T01:57:57.676Z","locale":"en","aggregateBytes":30126391,"clips":[{"id":"select-nodes","title":"Select nodes","description":"Hold Ctrl (Command on macOS) and click nodes to add them to the selection. Click a selected node with the same modifier to remove it.","kind":"micro","placements":["canvas-help:select","docs:additional-features"],"width":960,"height":640,"duration":23.666666666666668,"fps":15,"codec":"vp8","transcript":["Hold Ctrl / ⌘ and click two nodes to select them","Keep holding Ctrl / ⌘ and click another node to add it","Ctrl / ⌘ + click a selected node again to remove it"],"assets":{"video":{"file":"select-nodes.webm","mime":"video/webm","bytes":1845926,"sha256":"750482408bdaf175adc78bc5259ecd9158e8a085a79dc0f64c95b287fa7acd69"},"poster":{"file":"select-nodes.png","mime":"image/png","bytes":34266,"sha256":"0aba7d246e328e3c4fa2c424efb1174050732f9f610a94a5c3403d353505afee"},"captions":{"file":"select-nodes.vtt","mime":"text/vtt","bytes":268,"sha256":"4cb3846b572e1121f2fd27a10e4b62814f84df1bab0de1aeb8261bcc7b4d9ab8"}}},{"id":"move-selection","title":"Move a selection","description":"Select two nodes with Ctrl/Command-click, release the modifier, then drag either selected node. Both move together and retain their new positions.","kind":"micro","placements":["canvas-help:move","docs:additional-features"],"width":960,"height":640,"duration":19.6,"fps":15,"codec":"vp8","transcript":["Ctrl / ⌘ + click to select nodes","Release Ctrl / ⌘, then drag either selected node","Both nodes keep their new positions; other nodes stay put"],"assets":{"video":{"file":"move-selection.webm","mime":"video/webm","bytes":1039300,"sha256":"87c29aef89d4efe6bcf10db13f5faaf38bbc292968acfbe4d8306986f0f5dd39"},"poster":{"file":"move-selection.png","mime":"image/png","bytes":35164,"sha256":"303c97050d1d80b506132f9994816cd6529be755d90d006a81960b230abd9103"},"captions":{"file":"move-selection.vtt","mime":"text/vtt","bytes":250,"sha256":"3301a8e936e48737272e091046a5849afadf24865eef3b4189c3bf3798543aca"}}},{"id":"release-positions","title":"Release a moved node","description":"With Force layout enabled, drag a node, open View controls, select it through Moved, and use Release to remove its manual fixation.","kind":"micro","placements":["canvas-help:release","docs:additional-features"],"width":960,"height":640,"duration":26.533333333333335,"fps":15,"codec":"vp8","transcript":["With Force layout enabled, drag a node to fix its position","Open View controls on the right","Click Moved to select the fixed node","Click Release to remove the node’s manual fixation","Manual fixation is removed; Moved returns to 0"],"assets":{"video":{"file":"release-positions.webm","mime":"video/webm","bytes":2753054,"sha256":"0d9bc1fcaf97f0f874738d6ec87a2558ab4e0806545fa16a89a5d59fd6597b9a"},"poster":{"file":"release-positions.png","mime":"image/png","bytes":32730,"sha256":"798aab33dccab492cb0d9218b3cdffaf13818a8df0416296d540020725232226"},"captions":{"file":"release-positions.vtt","mime":"text/vtt","bytes":400,"sha256":"5639518bbfa667a680bfb7ac78601943248396c3bd6a2994a71cd9e2352fc54c"}}},{"id":"reset-positions","title":"Reset moved nodes","description":"Drag a node, open View controls, and click Moved to select manually positioned nodes. Reset restores their original positions; Undo reverses the reset.","kind":"micro","placements":["canvas-help:reset","canvas-help:undo","docs:additional-features"],"width":960,"height":640,"duration":28.466666666666665,"fps":15,"codec":"vp8","transcript":["Drag a node to a new position","Open View controls to find Moved, Reset, and Undo","Click Moved to select the node you dragged","Click Reset to restore its original position","Click Undo to return to the dragged position"],"assets":{"video":{"file":"reset-positions.webm","mime":"video/webm","bytes":1647785,"sha256":"809277a67549ccea95204a4c8a542a03f1b8711fb8d71bd2629fa9ef4423811e"},"poster":{"file":"reset-positions.png","mime":"image/png","bytes":36064,"sha256":"17d29db0b62c3b335b498d2df22ff11b9a43ccaaabab38c9608dbfaf37745922"},"captions":{"file":"reset-positions.vtt","mime":"text/vtt","bytes":385,"sha256":"94551a2bfc9102ff019ac300d2a2d8bc4e553918eaaced7b233c2dd8307fe867"}}},{"id":"rectangle-selection","title":"Select with a rectangle","description":"Hold Ctrl or Command and drag the canvas background around nodes to replace the selection.","kind":"micro","placements":["canvas-help:select","docs:additional-features"],"width":960,"height":640,"duration":19.466666666666665,"fps":15,"codec":"vp8","transcript":["Ctrl / ⌘ + click a node to start a selection","Ctrl / ⌘ + drag the background around two other nodes","The rectangle replaces the selection with these two nodes"],"assets":{"video":{"file":"rectangle-selection.webm","mime":"video/webm","bytes":1707905,"sha256":"8b7b506a5dbb59a15a774d9ff79734c64080adb515be31c54b973adee7fa563f"},"poster":{"file":"rectangle-selection.png","mime":"image/png","bytes":36492,"sha256":"3fa534f643bec383cea3bc222ea9d3495c5802a82e476f92b7a3e2056a2edbea"},"captions":{"file":"rectangle-selection.vtt","mime":"text/vtt","bytes":267,"sha256":"84370a0f5dd9c7b0d6b69cdd8c8b3f867a3ba9a56660813a0d6968017ae7ee76"}}},{"id":"rotate-selection","title":"Rotate a selection","description":"Select at least two elements, then hold Ctrl or Command and scroll to rotate them around the pointer.","kind":"micro","placements":["canvas-help:rotate","docs:additional-features"],"width":960,"height":640,"duration":18.4,"fps":15,"codec":"vp8","transcript":["Ctrl / ⌘ + click to select nodes","Hold Ctrl / ⌘ and scroll to rotate around the pointer","Both nodes keep their rotated positions"],"assets":{"video":{"file":"rotate-selection.webm","mime":"video/webm","bytes":1319504,"sha256":"deca612091671b20bb92de94219ecd530131726c7858a5fc700fea0a201e3a1e"},"poster":{"file":"rotate-selection.png","mime":"image/png","bytes":35484,"sha256":"e02e6f1b069233867536add827f22f1f6e665b241a3fa3afe2c10b0de9fd02db"},"captions":{"file":"rotate-selection.vtt","mime":"text/vtt","bytes":237,"sha256":"a73216fc1f16bcb9eb9f5fddd09b1b68f4f6804bd2796ac2e7144763aa83172a"}}},{"id":"place-text-annotation","title":"Place a text annotation","description":"Create a text label, then drag its unpositioned list entry onto the drawing.","kind":"walkthrough","placements":["docs:text-annotations"],"width":960,"height":640,"duration":31.8,"fps":15,"codec":"vp8","transcript":["Open the text annotation dialog","Enter Binding site as the annotation text","Turn on Bold to emphasize the label","Click OK to add the label to the list","The ? means unpositioned. Drag the label onto the drawing","The target icon confirms the label is positioned"],"assets":{"video":{"file":"place-text-annotation.webm","mime":"video/webm","bytes":1805790,"sha256":"ae88b861c68074444bc559d6918082841fcd727666df5b96851e1c749ac697fb"},"poster":{"file":"place-text-annotation.png","mime":"image/png","bytes":47078,"sha256":"0fa16c8dac23a794b9972e160c6682964b82b9d7280b29a09bedf0fa577ef150"},"captions":{"file":"place-text-annotation.vtt","mime":"text/vtt","bytes":460,"sha256":"a6d615932bd634404b7b1dc42b91d89f0ed153a06f8cc7289f9caedd39b1b2f9"}}},{"id":"import-fasta","title":"Import FASTA input","description":"Paste two FASTA records, inspect the parsed sequences, structures, and names, then render them.","kind":"walkthrough","placements":["docs:fasta-input"],"width":960,"height":640,"duration":24,"fps":15,"codec":"vp8","transcript":["Open FASTA Data to import RNA records","Paste two FASTA records with sequences and structures","Check the parsed sequences, structures, and names","Click OK to render the imported RNA"],"assets":{"video":{"file":"import-fasta.webm","mime":"video/webm","bytes":976555,"sha256":"9d51e0724747687e826cbb42f7675942a4d09e8f6f835e9982c7c58269e97b82"},"poster":{"file":"import-fasta.png","mime":"image/png","bytes":41381,"sha256":"2751a290f50e63af1648205bd8c7551e77c01b3b11e3a3adc90f370e58679fae"},"captions":{"file":"import-fasta.vtt","mime":"text/vtt","bytes":317,"sha256":"491b72690b55a39ebd346ccf2f91ab3fcb40988be595db7f15245193cfb2e8c3"}}},{"id":"apply-probability-profile","title":"Apply a probability profile","description":"Enter profile values, choose biological start-index coordinates, and apply the nucleotide color gradient.","kind":"walkthrough","placements":["docs:probability-profiles"],"width":960,"height":640,"duration":30.6,"fps":15,"codec":"vp8","transcript":["Enter each nucleotide index and its probability for RNA 1.","Choose Start 1: indices are -2, -1, 1, 2, skipping zero.","Uncheck =1 so purple represents zero; higher values fade the overlay.","Click Apply to color RNA 1 using the probability profile.","Lower probabilities have stronger purple shading on RNA 1."],"assets":{"video":{"file":"apply-probability-profile.webm","mime":"video/webm","bytes":1784731,"sha256":"797063d594fd3c058fbbf252bc863903283dbfe71733857d18516bc2f0118dac"},"poster":{"file":"apply-probability-profile.png","mime":"image/png","bytes":41707,"sha256":"8b4ee66d320261883a0fc69177856dc99c736338b3dc31d7bb06af3908cd5157"},"captions":{"file":"apply-probability-profile.vtt","mime":"text/vtt","bytes":475,"sha256":"45fbe35402481f15d3d11337da1d0a7581e4d790acd87bbadd0af96c1d99ee86"}}},{"id":"store-load-session","title":"Store and load a session","description":"Download the current JSON session, clear the viewer, and load the file to restore the figure.","kind":"walkthrough","placements":["docs:saved-session","docs:json-session"],"width":960,"height":640,"duration":24.866666666666667,"fps":15,"codec":"vp8","transcript":["Click Store to download this session as a JSON file","Click Clear to empty the inputs and remove the figure","Click Load, then choose vaRRI_session.json in the file picker","The saved inputs and RNA figure are restored"],"assets":{"video":{"file":"store-load-session.webm","mime":"video/webm","bytes":1193427,"sha256":"98df8b550f134c8c190957b52a7a0d4f931b1e807879c3a024af6f8bb91466bc"},"poster":{"file":"store-load-session.png","mime":"image/png","bytes":49778,"sha256":"cc7c565f413816624ff2c8d4a0f24e431ee9c6a0d876824a1ad3befc35396f79"},"captions":{"file":"store-load-session.vtt","mime":"text/vtt","bytes":352,"sha256":"1f556c4b0a6663cfa773530d4b2bf4e8536b46165f30435e958df4cf33114475"}}},{"id":"export-png","title":"Export a publication PNG","description":"Set pixel dimensions and print density in the PNG export dialog, then download the image.","kind":"walkthrough","placements":["docs:export"],"width":960,"height":640,"duration":29.533333333333335,"fps":15,"codec":"vp8","transcript":["Click PNG to choose image size and print density","Set width to 1200 pixels; height follows the aspect ratio","Set DPI to 300; the pixel dimensions stay the same","Click Export PNG to download the image","PNG downloaded: 1200 × 1008 pixels at 300 DPI"],"assets":{"video":{"file":"export-png.webm","mime":"video/webm","bytes":1799011,"sha256":"a9faff988059a7761c389ae102351d101182403b79cfaa7b08ab05171c0aa819"},"poster":{"file":"export-png.png","mime":"image/png","bytes":51002,"sha256":"2cdae9dd5ccb0a1ec596770c8100fd27d9fd04a2e51621c5d7c8f1adcbb1f218"},"captions":{"file":"export-png.vtt","mime":"text/vtt","bytes":416,"sha256":"24241dcb1fceac84719d375da3a07050cc9022186515b0237e00690a67658894"}}},{"id":"add-region-highlight","title":"Add a paired region highlight","description":"Enter one range for each RNA strand and add a translucent paired-region highlight.","kind":"walkthrough","placements":["docs:region-highlights"],"width":960,"height":640,"duration":32.93333333333333,"fps":15,"codec":"vp8","transcript":["Click Add in Region Highlights","Enter 1-2 for the first RNA strand","Enter 3-4 for its partner on the second RNA","Set opacity to 0.5 for a translucent highlight","Click OK to add the highlight to the drawing","The paired region is highlighted and listed on the left"],"assets":{"video":{"file":"add-region-highlight.webm","mime":"video/webm","bytes":1749917,"sha256":"87bbfa04ea15f5b7d25ff54aa99ffc0c0a8a775fce0b8d00cff22f266344bfa6"},"poster":{"file":"add-region-highlight.png","mime":"image/png","bytes":44027,"sha256":"89dc2d6d6da520676e2998da7b5b2bd60b7457401932ecb8a1aab510863b75e2"},"captions":{"file":"add-region-highlight.vtt","mime":"text/vtt","bytes":463,"sha256":"79231d3517affdb966f202c11e80d6fb483fa29a2da196fc3860b7dd1513f0c7"}}},{"id":"choose-example","title":"Choose an example","description":"Choose Mutations & highlighting to load its RNA input, four mutations, and a subsequence highlight.","kind":"walkthrough","placements":["docs:example-input","input-panel:examples"],"width":960,"height":640,"duration":18.8,"fps":15,"codec":"vp8","transcript":["Open Example to browse the built-in examples","Choose Mutations & highlighting","The inputs and figure include four mutations and a green highlight"],"assets":{"video":{"file":"choose-example.webm","mime":"video/webm","bytes":900032,"sha256":"2d7beaf1ff36a52d7ea5f5ae0cad236ad8cdb615524b7bd038f75bd0e4eb24ec"},"poster":{"file":"choose-example.png","mime":"image/png","bytes":50374,"sha256":"ea521adb59191ad676cea7bf0e742feea7f4c2bbbff81920a5d3863661ea862b"},"captions":{"file":"choose-example.vtt","mime":"text/vtt","bytes":254,"sha256":"4646d37ba7851628beb4b5297b63490a88f5d40fd83c2c527001b10a578d98db"}}},{"id":"name-and-number-strand","title":"Name and number a strand","description":"Commit a strand name and a negative biological start index, with numbering that skips zero.","kind":"walkthrough","placements":["docs:sequence-and-structure-input-fields","input-panel:numbering"],"width":960,"height":640,"duration":22.8,"fps":15,"codec":"vp8","transcript":["Name the first strand Leader RNA, then press Enter","Set Start 1 to -2, then press Enter","Numbering skips zero between -1 and 1"],"assets":{"video":{"file":"name-and-number-strand.webm","mime":"video/webm","bytes":1157822,"sha256":"f9dd082ac07bf70677cf9af5fa9f64e93bbdbce50099ab768f705f6b57ad503a"},"poster":{"file":"name-and-number-strand.png","mime":"image/png","bytes":47372,"sha256":"cbbbfc765cf3ce0297cab9ce6d8bf1538969330a10623b3c3587f55949c99227"},"captions":{"file":"name-and-number-strand.vtt","mime":"text/vtt","bytes":231,"sha256":"39499da9eb54c1f3d336a8aa26037bb0e5369e669819a19e2e17ef1451bb17eb"}}},{"id":"add-subsequence-highlight","title":"Add a subsequence highlight","description":"Choose the second strand and highlight biological positions 102–103 at half opacity.","kind":"walkthrough","placements":["docs:subsequence-highlights","input-panel:subsequences"],"width":960,"height":640,"duration":30.466666666666665,"fps":15,"codec":"vp8","transcript":["Click Add in Subsequence Highlights","Choose Seq. 2 for the second RNA strand","Enter biological range 102-103","Set opacity to 0.5","Click OK to add the highlight","The purple range is highlighted and listed"],"assets":{"video":{"file":"add-subsequence-highlight.webm","mime":"video/webm","bytes":2025794,"sha256":"330b64828c80922f2766032aa67a8ef0f29197e5b13c13c4ff90bcf5d50eefd8"},"poster":{"file":"add-subsequence-highlight.png","mime":"image/png","bytes":44281,"sha256":"f7b83e022a544a6bb4486cfa1c4181fe4d9f05ac464d42cdb4d73d9db548d92d"},"captions":{"file":"add-subsequence-highlight.vtt","mime":"text/vtt","bytes":404,"sha256":"4a5de1986c90e861170ffcb36ac63cfdf2bf1899f1884525bbe9ec0155a9858b"}}},{"id":"add-point-mutation","title":"Annotate a point mutation","description":"Annotate G102A on the second strand of the supplied structure without editing the input sequence or predicting a fold.","kind":"walkthrough","placements":["docs:point-mutations","input-panel:mutations"],"width":960,"height":640,"duration":35.8,"fps":15,"codec":"vp8","transcript":["Click Add in Point Mutations to annotate the supplied structure","Choose Seq. 2 for the second RNA strand","Enter biological position 102","Enter A in To for the replacement annotation","Click OK to add the G102A annotation","G102A adds a green outline and A label; inputs stay unchanged and no fold is predicted"],"assets":{"video":{"file":"add-point-mutation.webm","mime":"video/webm","bytes":1169740,"sha256":"849ffb198537e8b6997c19ed455f76f57579ddd08ebe121f0a7e8a46b9e2ccc3"},"poster":{"file":"add-point-mutation.png","mime":"image/png","bytes":43192,"sha256":"47db36e5b608c31310d7f2ec4274cfca9ebd3e0e0650c5a66cf98554b0351f3a"},"captions":{"file":"add-point-mutation.vtt","mime":"text/vtt","bytes":508,"sha256":"17c53f03bfbcb3bb52f3791f5c7cac347ac1bc1352efef4b8b59f82644930cb9"}}},{"id":"highlight-rri-nucleotides","title":"Highlight RRI nucleotides","description":"Compare outlines around all ten interaction-region nucleotides with the eight paired nucleotides, excluding the two unpaired A bases.","kind":"walkthrough","placements":["docs:visualisation-settings","input-panel:rri-nucleotides"],"width":960,"height":640,"duration":30.666666666666668,"fps":15,"codec":"vp8","transcript":["Choose region for RRI nucleotide highlighting","Region outlines all 10 nucleotides, including both unpaired A bases","Choose basepairs to outline only paired nucleotides","Basepairs outlines 8 nucleotides; both unpaired A bases lose the red outline"],"assets":{"video":{"file":"highlight-rri-nucleotides.webm","mime":"video/webm","bytes":1004721,"sha256":"c8f2aa06981b38f36351768936adba9858b8368b07c3fa3b3e0a3a43f7d0bc66"},"poster":{"file":"highlight-rri-nucleotides.png","mime":"image/png","bytes":56890,"sha256":"65b4dbbc9859f07ca0a19a95cf2317e97fdedaa692935a1a96e77b0003c9ca51"},"captions":{"file":"highlight-rri-nucleotides.vtt","mime":"text/vtt","bytes":382,"sha256":"ee844f6d708f79ec5ab75721190d9733027ae48d704aa1f780a84944908c7ed2"}}},{"id":"highlight-rri-background","title":"Highlight the RRI background","description":"Use the RRI background dropdown to generate one whole-region highlight, separate paired-stack highlights, or remove the automatic highlights.","kind":"walkthrough","placements":["docs:region-highlights","input-panel:rri-background"],"width":960,"height":640,"duration":39.46666666666667,"fps":15,"codec":"vp8","transcript":["Choose region: this dropdown controls automatic RRI backgrounds","Region adds one generated highlight: 1-5&1-5","Choose basepairs to separate the two paired stacks","Two generated highlights leave the unpaired A bases clear","Choose nothing to remove the automatic highlights","The generated entries and backgrounds are gone; the input is unchanged"],"assets":{"video":{"file":"highlight-rri-background.webm","mime":"video/webm","bytes":2864747,"sha256":"fb8be31b954e79cb91c056dd61db672a586c2a24e7e6a57a70a9258ae633a117"},"poster":{"file":"highlight-rri-background.png","mime":"image/png","bytes":49862,"sha256":"d66ae39022609a87806aad41aca498fbe10b892f272d0e68e07ac7bb2e9defcf"},"captions":{"file":"highlight-rri-background.vtt","mime":"text/vtt","bytes":548,"sha256":"27458a7355bd273685a97365657b04eb6409940266197b2e05855972d1d6682a"}}},{"id":"linearize-rri-layout","title":"Make the RRI horizontal","description":"Enable Linear horizontal RRI layout to create stable paired rails while Force layout stays off.","kind":"walkthrough","placements":["docs:visualisation-settings","input-panel:linear-layout"],"width":960,"height":640,"duration":17.333333333333332,"fps":15,"codec":"vp8","transcript":["With Force layout off, enable Linear horizontal RRI layout","Paired bases now form horizontal rails; Force layout stays off"],"assets":{"video":{"file":"linearize-rri-layout.webm","mime":"video/webm","bytes":538142,"sha256":"86c42b7982f622330be990acc4b0b7de09f3d248b28602baeb6f1d69863e287a"},"poster":{"file":"linearize-rri-layout.png","mime":"image/png","bytes":58532,"sha256":"84fd842984b4c042d5fc14aa057c96712131ccbbc8aed2aab1d6a23a24724fcc"},"captions":{"file":"linearize-rri-layout.vtt","mime":"text/vtt","bytes":195,"sha256":"adca1e92b4cedb64895a05d462b4103cd53e66514601ac77a54369ac0d8066d9"}}}]} diff --git a/doc/demos/move-selection.png b/doc/demos/move-selection.png new file mode 100644 index 0000000..448e3b9 Binary files /dev/null and b/doc/demos/move-selection.png differ diff --git a/doc/demos/move-selection.vtt b/doc/demos/move-selection.vtt new file mode 100644 index 0000000..6aceb6c --- /dev/null +++ b/doc/demos/move-selection.vtt @@ -0,0 +1,13 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:06.400 +Ctrl / ⌘ + click to select nodes + +2 +00:00:06.400 --> 00:00:12.867 +Release Ctrl / ⌘, then drag either selected node + +3 +00:00:12.867 --> 00:00:19.600 +Both nodes keep their new positions; other nodes stay put diff --git a/doc/demos/move-selection.webm b/doc/demos/move-selection.webm new file mode 100644 index 0000000..c2cbf67 Binary files /dev/null and b/doc/demos/move-selection.webm differ diff --git a/doc/demos/name-and-number-strand.png b/doc/demos/name-and-number-strand.png new file mode 100644 index 0000000..2e4efda Binary files /dev/null and b/doc/demos/name-and-number-strand.png differ diff --git a/doc/demos/name-and-number-strand.vtt b/doc/demos/name-and-number-strand.vtt new file mode 100644 index 0000000..af30bbe --- /dev/null +++ b/doc/demos/name-and-number-strand.vtt @@ -0,0 +1,13 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:08.200 +Name the first strand Leader RNA, then press Enter + +2 +00:00:08.200 --> 00:00:16.067 +Set Start 1 to -2, then press Enter + +3 +00:00:16.067 --> 00:00:22.800 +Numbering skips zero between -1 and 1 diff --git a/doc/demos/name-and-number-strand.webm b/doc/demos/name-and-number-strand.webm new file mode 100644 index 0000000..6650935 Binary files /dev/null and b/doc/demos/name-and-number-strand.webm differ diff --git a/doc/demos/place-text-annotation.png b/doc/demos/place-text-annotation.png new file mode 100644 index 0000000..32dbb06 Binary files /dev/null and b/doc/demos/place-text-annotation.png differ diff --git a/doc/demos/place-text-annotation.vtt b/doc/demos/place-text-annotation.vtt new file mode 100644 index 0000000..d4849ac --- /dev/null +++ b/doc/demos/place-text-annotation.vtt @@ -0,0 +1,25 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:04.267 +Open the text annotation dialog + +2 +00:00:04.267 --> 00:00:09.267 +Enter Binding site as the annotation text + +3 +00:00:09.267 --> 00:00:13.800 +Turn on Bold to emphasize the label + +4 +00:00:13.800 --> 00:00:18.933 +Click OK to add the label to the list + +5 +00:00:18.933 --> 00:00:25.733 +The ? means unpositioned. Drag the label onto the drawing + +6 +00:00:25.733 --> 00:00:31.800 +The target icon confirms the label is positioned diff --git a/doc/demos/place-text-annotation.webm b/doc/demos/place-text-annotation.webm new file mode 100644 index 0000000..4a646b8 Binary files /dev/null and b/doc/demos/place-text-annotation.webm differ diff --git a/doc/demos/rectangle-selection.png b/doc/demos/rectangle-selection.png new file mode 100644 index 0000000..5c4d3f6 Binary files /dev/null and b/doc/demos/rectangle-selection.png differ diff --git a/doc/demos/rectangle-selection.vtt b/doc/demos/rectangle-selection.vtt new file mode 100644 index 0000000..c2e2528 --- /dev/null +++ b/doc/demos/rectangle-selection.vtt @@ -0,0 +1,13 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:05.733 +Ctrl / ⌘ + click a node to start a selection + +2 +00:00:05.733 --> 00:00:13.333 +Ctrl / ⌘ + drag the background around two other nodes + +3 +00:00:13.333 --> 00:00:19.467 +The rectangle replaces the selection with these two nodes diff --git a/doc/demos/rectangle-selection.webm b/doc/demos/rectangle-selection.webm new file mode 100644 index 0000000..73bbdf9 Binary files /dev/null and b/doc/demos/rectangle-selection.webm differ diff --git a/doc/demos/release-positions.png b/doc/demos/release-positions.png new file mode 100644 index 0000000..0766e22 Binary files /dev/null and b/doc/demos/release-positions.png differ diff --git a/doc/demos/release-positions.vtt b/doc/demos/release-positions.vtt new file mode 100644 index 0000000..9cb0185 --- /dev/null +++ b/doc/demos/release-positions.vtt @@ -0,0 +1,21 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:07.067 +With Force layout enabled, drag a node to fix its position + +2 +00:00:07.067 --> 00:00:11.333 +Open View controls on the right + +3 +00:00:11.333 --> 00:00:15.867 +Click Moved to select the fixed node + +4 +00:00:15.867 --> 00:00:20.667 +Click Release to remove the node’s manual fixation + +5 +00:00:20.667 --> 00:00:26.533 +Manual fixation is removed; Moved returns to 0 diff --git a/doc/demos/release-positions.webm b/doc/demos/release-positions.webm new file mode 100644 index 0000000..fba4f58 Binary files /dev/null and b/doc/demos/release-positions.webm differ diff --git a/doc/demos/reset-positions.png b/doc/demos/reset-positions.png new file mode 100644 index 0000000..6d2c24b Binary files /dev/null and b/doc/demos/reset-positions.png differ diff --git a/doc/demos/reset-positions.vtt b/doc/demos/reset-positions.vtt new file mode 100644 index 0000000..a2ca463 --- /dev/null +++ b/doc/demos/reset-positions.vtt @@ -0,0 +1,21 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:05.867 +Drag a node to a new position + +2 +00:00:05.867 --> 00:00:11.000 +Open View controls to find Moved, Reset, and Undo + +3 +00:00:11.000 --> 00:00:15.800 +Click Moved to select the node you dragged + +4 +00:00:15.800 --> 00:00:21.133 +Click Reset to restore its original position + +5 +00:00:21.133 --> 00:00:28.467 +Click Undo to return to the dragged position diff --git a/doc/demos/reset-positions.webm b/doc/demos/reset-positions.webm new file mode 100644 index 0000000..abada99 Binary files /dev/null and b/doc/demos/reset-positions.webm differ diff --git a/doc/demos/rotate-selection.png b/doc/demos/rotate-selection.png new file mode 100644 index 0000000..88b2310 Binary files /dev/null and b/doc/demos/rotate-selection.png differ diff --git a/doc/demos/rotate-selection.vtt b/doc/demos/rotate-selection.vtt new file mode 100644 index 0000000..c88cd1b --- /dev/null +++ b/doc/demos/rotate-selection.vtt @@ -0,0 +1,13 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:06.400 +Ctrl / ⌘ + click to select nodes + +2 +00:00:06.400 --> 00:00:13.067 +Hold Ctrl / ⌘ and scroll to rotate around the pointer + +3 +00:00:13.067 --> 00:00:18.400 +Both nodes keep their rotated positions diff --git a/doc/demos/rotate-selection.webm b/doc/demos/rotate-selection.webm new file mode 100644 index 0000000..ab22d9e Binary files /dev/null and b/doc/demos/rotate-selection.webm differ diff --git a/doc/demos/select-nodes.png b/doc/demos/select-nodes.png new file mode 100644 index 0000000..de4e26b Binary files /dev/null and b/doc/demos/select-nodes.png differ diff --git a/doc/demos/select-nodes.vtt b/doc/demos/select-nodes.vtt new file mode 100644 index 0000000..3956cfe --- /dev/null +++ b/doc/demos/select-nodes.vtt @@ -0,0 +1,13 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:07.933 +Hold Ctrl / ⌘ and click two nodes to select them + +2 +00:00:07.933 --> 00:00:14.733 +Keep holding Ctrl / ⌘ and click another node to add it + +3 +00:00:14.733 --> 00:00:23.667 +Ctrl / ⌘ + click a selected node again to remove it diff --git a/doc/demos/select-nodes.webm b/doc/demos/select-nodes.webm new file mode 100644 index 0000000..8188cb7 Binary files /dev/null and b/doc/demos/select-nodes.webm differ diff --git a/doc/demos/store-load-session.png b/doc/demos/store-load-session.png new file mode 100644 index 0000000..10dd647 Binary files /dev/null and b/doc/demos/store-load-session.png differ diff --git a/doc/demos/store-load-session.vtt b/doc/demos/store-load-session.vtt new file mode 100644 index 0000000..7b0b85a --- /dev/null +++ b/doc/demos/store-load-session.vtt @@ -0,0 +1,17 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:05.467 +Click Store to download this session as a JSON file + +2 +00:00:05.467 --> 00:00:12.133 +Click Clear to empty the inputs and remove the figure + +3 +00:00:12.133 --> 00:00:19.333 +Click Load, then choose vaRRI_session.json in the file picker + +4 +00:00:19.333 --> 00:00:24.867 +The saved inputs and RNA figure are restored diff --git a/doc/demos/store-load-session.webm b/doc/demos/store-load-session.webm new file mode 100644 index 0000000..2f7fe5a Binary files /dev/null and b/doc/demos/store-load-session.webm differ diff --git a/docs/sharing-and-input.md b/docs/sharing-and-input.md index e4842b3..1ac1476 100644 --- a/docs/sharing-and-input.md +++ b/docs/sharing-and-input.md @@ -74,6 +74,8 @@ appears and the current session remains intact. Unknown field names and unsuppor format versions are rejected. Save a working figure first, then edit that file for the most complete starting point. +
+ ## URL Parameters & Sharing **vaRRI** supports state persistence directly via URL parameters, allowing you to pre-fill inputs or share specific visualization configurations using the **🔗 Share Link** button in the export panel. Most parameter names map directly to their corresponding HTML element IDs. diff --git a/docs/viewer-guide.md b/docs/viewer-guide.md index f30889f..a596d0c 100644 --- a/docs/viewer-guide.md +++ b/docs/viewer-guide.md @@ -33,6 +33,16 @@ | **Color Choice** | Use the color pickers to customize the colors for each sequence. | | **Structure** | Dot-bracket structure string. Separate two molecules with `&`. | +Small, unboxed play icons beside the left-hand controls open the corresponding guide. +Hover or focus an icon to preview its video; click or tap to keep it open with +playback controls, captions, a transcript, and a download. Descriptions stay inside +the player's collapsed **Details and transcript** section. Escape, the close +button, or closing the panel stops the video. Instructions are included in the +video; optional captions can be enabled through its controls. Reduced-motion +users press Play or activate the icon to start playback. + +
+ ### FASTA Sequence (and Structure) Input The **Sequence** field content can be automatically generated from **FASTA input**. @@ -55,6 +65,8 @@ ACGAAAAAAAGAGCAUACGACAGUAG Details about supported sequence and dot-bracket structure encodings are provided in the [Input Format Reference](sharing-and-input.md#input-format-reference) section. +
+ ### Load a Saved Session Use **⬆ Load** at the right of the **Sequence & Structure Input** heading to @@ -64,6 +76,8 @@ the current figure and render automatically. Invalid files show an error without replacing your work. See [JSON Session Files](sharing-and-input.md#json-session-files) for the editable file format. +
+ ### Example Input When loading the page, a feature-overview RNA-RNA interaction is automatically rendered. @@ -74,6 +88,8 @@ Open the **Example** dropdown to choose another RNA-RNA interaction. Each open m | **Example dropdown** | Loads the feature overview or an existing literature example demonstrating SHAPE profiles, cropping and highlights, or mutations and sequence context. | | **✕ Clear** | Clears all input fields, resets the visualisation, and clears the example selection. | +
+ ### Visualisation Settings @@ -92,6 +108,9 @@ Open the **Example** dropdown to choose another RNA-RNA interaction. Each open m | - **Free trailing ends** | When checked, the trailing ends of the sequences are not fixed in the force-directed layout and can move freely. | | - **Pull Pseudoknot Basepairs** | When checked, pulls pseudoknot basepairs together and stabilizes stacked pairs in the force-directed layout. | +
+
+ ### Region Highlights Add colored highlightings to paired regions of the two input sequences via the following fields and using "Add" button. @@ -116,6 +135,9 @@ Behaviour of generated list entries: - When set to `basepairs`, vaRRI computes one or more generated region entries that correspond to intermolecular basepair stacks and displays them as non-removable entries. - Generated entries are refreshed automatically from the current structure and settings; they are not exported as user-defined region highlights in share links. +
+
+ ### Subsequence Highlights @@ -130,6 +152,8 @@ Selecting a listed highlighting will populate the input fields with its values f | **Range** | The start and end indices of the subsequence to highlight in the form `start-end`. | | **Color** | The color to use for highlighting the subsequence. | +
+ ### Probability Profiles Sometimes no homogenous coloring is desired, but rather a gradient of colors to represent the weight or importance of nucleotides w.r.t. a certain property. @@ -175,6 +199,12 @@ Finally, the following fields are available to define the visualization of the p > [!IMPORTANT] > The indices of the given probability profile are validated against the sequence and start index of the respective molecule, and a warning is shown if any indices are invalid. +The demo below uses **Start 1** (RNA 1's start index) set to **-2**. Its four profile positions +are `-2`, `-1`, `1`, and `2`: biological numbering skips zero. Set that start index +in **Sequence & Structure Input** before trying the same values. + +
+ ### Point Mutations RNA-RNA interaction visualizations are often used to discuss the effect of point mutations on the interaction. @@ -197,6 +227,11 @@ All registered mutations are shown in a list above the input fields, and can be The list shows the mutations in the standard mutation notation, e.g. `A23G` for a mutation from A to G at position 23, extracting the original nucleotide from the input sequence to avoid mistakes. Selecting a listed mutation will populate the input fields with its values for editing. +A point mutation is an annotation on the supplied structure. It does not change +the input sequence or predict a new folded structure. + +
+ ### Text Annotations This panel is immediately below **Visualization Settings**. Select **Add** to @@ -217,6 +252,8 @@ The initial **Seq. 1** and **Seq. 2** labels follow their sequence endpoints unt you drag them. Positioned labels appear in SVG and PNG exports. Share links save both positioned and unpositioned labels, including their formatting. +
+ 4. Changing selects and checkboxes rerenders immediately. Typed fields rerender when you commit the edit by leaving the field, and single-line inputs also rerender when you press Enter. @@ -229,9 +266,15 @@ both positioned and unpositioned labels, including their formatting. - **Rotation**: Use the *Rotation* slider in *View controls* to rotate the structure. Rotation preserves text orientation and is useful to align the structure for better visibility or to match a certain orientation in a publication figure. - **Cropping**: Use the *Cropping* slider in *View controls* to reduce the unpaired nucleotides at the ends of each sequence to the given number. This is useful to focus on the interaction region and reduce the size of the visualisation. A value of `-1` disables cropping and shows the full sequences. - **Selection**: Ctrl-click a nucleotide, numbering label, or text label to add it to or remove it from the selection. Ctrl-drag the background to replace the selection with all elements fully inside the rectangle. An empty rectangle, Ctrl-click on the background, or a plain click on an element clears the selection. Command also works on macOS. -- **Moving elements**: Drag any nucleotide, numbering label, or text label in either layout mode. Drag a selected element to move the whole selection; dragging an unselected element clears the selection and moves that element alone. Released nodes and numbering labels stay in place until the next render, including in force mode. +
+
+- **Moving elements**: Drag any nucleotide, numbering label, or text label in either layout mode. Drag a selected element to move the whole selection; dragging an unselected element clears the selection and moves that element alone. After a drag, moved nucleotides and numbering labels stay fixed until the next render, including in force mode. +
- **Manual positions**: The **Moved** button in *View controls* counts manually positioned nucleotides and numbering labels; click it to select them all. **Release** is enabled only in Force layout mode; it unfixes selected nodes at their current positions, allows them to move again, and removes them from the Moved count. **Reset** restores selected manual nodes to their positions and fixation before the first edit. Text labels are not counted, released, or reset. **Undo** (or Ctrl+Z / Command+Z while the canvas is focused) reverses the last drag, selection rotation, release, or reset. Undo a release before resetting that node to its original position. History is kept for the current rendering. The **Help** button opens the full interaction guide. +
+
- **Selection rotation**: With at least two elements selected, hold Ctrl (or Command) and use the mouse wheel to rotate them around the pointer. The ordinary wheel still zooms. +
- **Saving positions**: SVG and PNG exports preserve the current layout and omit selection outlines. Share links and JSON sessions save text-label positions; manually moved nucleotide and numbering positions last only for the current rendering. - **Nucleotide tooltips**: Hover to see the nucleotide index and probability value (if present). - **Resize Canvas**: The visualisation canvas size can be adjusted by dragging the bottom-right corner of the canvas. This is useful when visualizing large interactions on large screens, or when preparing figures for publication. The canvas size is preserved when exporting the visualisation. @@ -272,4 +315,6 @@ Click **Export PNG** to start the normal browser download. The browser's downloa settings determine whether it prompts for a file name and location or saves to the default download folder. **Cancel** or Escape closes the dialog without saving. +
+ --- diff --git a/package.json b/package.json index ebfd442..1277905 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,10 @@ }, "scripts": { "build": "node scripts/build-core.js", + "demos": "node recording/record.js", + "demos:preview": "node recording/preview.js", + "demos:publish": "node recording/publish.js", + "demos:check-published": "node recording/publish.js --check", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand", "test:architecture": "node scripts/check-architecture.js", diff --git a/recording/AGENTS.md b/recording/AGENTS.md new file mode 100644 index 0000000..d758ebd --- /dev/null +++ b/recording/AGENTS.md @@ -0,0 +1,76 @@ +# Extending the vaRRI recorder + +The repository's root AGENTS.md also applies. Read [README.md](README.md) first. +This directory contains development tools; generation needs no AI service. +It uses the parent repository's pinned npm dependencies and live viewer source. + +## Where to work + +- Add a demonstration in `scenes/-.js` and register its default + export in `scenes/index.js`. Keep one user task per scene. +- Start from `scenes/move-selection.js` for selection/dragging or + `scenes/reset-positions.js` for button interactions. +- Use `config.js` for shared viewport and input defaults. Prefer a scene's + `parameters`, `stage`, or `zoom` override and `prepare(page)` hook for setup. +- Put reusable gestures in `lib/capture.js`; put shared assertions and locators + in `scenes/helpers.js`. Keep scene-specific expectations inside that scene. +- Presentation belongs in `lib/stage.css` and the gallery files. Select an existing + stage profile first; use `prepare(page)` for additional scene-specific framing. +- `record.js` owns recording and generated preview output. `publish.js` separately + promotes reviewed assets into the served catalog. `lib/verify.js` checks encoded + playback. Preserve their checks when extending the recorder. + +## Scene contract + +Export `{ id, title, description, placements, parameters?, stage?, zoom?, prepare?, +async run(capture, page) }`. Use unique lowercase hyphenated IDs; they become output +filenames. `placements` is a required nonempty array describing intended help +locations, such as `docs:additional-features`; it does not create UI anchors. +Add a matching `data-help-demo` anchor beside written instructions when integrating +a new scene, and republish the complete catalog after review. + +`parameters` contains existing vaRRI URL fields. `stage` is `canvas` (default), +`full-ui`, `dialog`, or `export-dialog`; `zoom: false` disables setup wheel zoom. +`prepare(page)` runs after common framing, readiness, and zoom and before the +poster/capture; use it for unrecorded, scene-specific setup. All visible +demonstrated actions belong in `run`, using capture helpers documented in README. + +Exercise real keyboard and pointer events. Do not assign graph coordinates, +selected flags, or artificial outlines to make an interaction appear successful. +Read state/geometry for assertions, and make a broken feature fail the recording. +The `node(page, index)` helper uses zero-based DOM order, not biological indices. +Release held keys and mouse buttons. Wait for renders/dialogs after UI changes. + +Every scene should begin with an instruction, allow reading time, show one short +task, assert its observable result, and hold the final state. Stay within the +45-second and 4096-KiB defaults unless the task explicitly needs different limits. +The capture helpers enforce a reading pause and clear centered instruction cards +before gestures. Do not bypass them with direct pointer actions in a scene. +At least one nonempty, single-line caption is required. Capture frames between +caption changes so their start times increase; the same cues form the transcript. +`capture.hold()` repeats a still frame; it cannot demonstrate running physics. +Do not call the output byte-identical across browser, font, or encoder versions. + +## Verify changes + +From the repository root: + +```bash +npm run demos -- --list +npm run demos -- --only YOUR-SCENE-ID +npm run demos:preview +npm run test:architecture +``` + +Watch the complete clip and inspect its poster and decoded `-review.png`. +Check labels, pointer alignment, captions, size, and the asserted behavior. +If changing shared capture, fixture, framing, or encoding code, run `npm run demos` +for all scenes. Root PR CI also runs all scenes. Follow the root instructions +for the other required pre-PR checks; do not overwrite visual baselines. + +Generated previews stay under the ignored `output/playwright/` tree. Published +help assets live in tracked `doc/demos/`; update them only when publication is in +scope. Include source, documentation, and workflow changes in the PR. Manually +review the complete catalog before `npm run demos:publish`: checksum and source +revision validation cannot attest that human review occurred. Record which scenes +and encoders were verified, and run `npm run demos:check-published` after promotion. diff --git a/recording/README.md b/recording/README.md new file mode 100644 index 0000000..5326ea1 --- /dev/null +++ b/recording/README.md @@ -0,0 +1,313 @@ +# Recording pipeline + +The demo pipeline drives the real vaRRI viewer with Playwright and sends rendered +frames to FFmpeg. A scene describes a user task, shows a pointer and instructions, +and checks the result. Instructions appear centered on a dark video-guide card, +separate from the viewer. Each card is held for at least 2.4 seconds (longer for +more text), then cleared before the pointer moves or the action starts. Amber +rings, target outlines, and gesture labels identify clicks, drags, scrolling, +keyboard actions, and edited fields. Rerun scenes after changing the interface to refresh the +videos. No production JavaScript dependency or application build is added. + +Generation is ordinary JavaScript, Playwright, and FFmpeg: no AI model, account, +or API key is used. This folder holds the recorder, scenes, presentation assets, +and explanation together. It uses the parent vaRRI checkout and its pinned +development dependencies; it is not an independent npm package. + +Agents extending the recorder should start with [AGENTS.md](AGENTS.md). + +## Folder map + +| File | Responsibility | +| --- | --- | +| `record.js` | CLI, scene execution, size checks, manifest, verified output | +| `publish.js` | Validated promotion into the served `doc/demos/` catalog after manual review | +| `preview.js` | Local gallery server | +| `config.js` | Shared viewport and RNA input defaults | +| `scenes/index.js` | Scene registration and metadata validation | +| `scenes/*.js` | One demo per file, plus shared locators/assertions | +| `lib/capture.js` | Viewer setup, actual gestures, screenshot timing, encoding | +| `lib/runtime.js` | Repository location, encoder discovery, local HTTP server | +| `lib/stage.css` | Canvas framing and pointer/caption styling | +| `lib/presentation.js` | Centered instruction cards and recorded gesture effects | +| `lib/gallery*` | Preview HTML, styling, and accessible playback behavior | +| `lib/verify.js` | Browser playback and decoded-frame checks | + +`../package.json` exposes the npm commands. `../.github/workflows/demos.yml` +provides manual artifact generation; the existing PR workflow also runs the demos. + +## Generate and preview + +From the repository root, with Node.js 24: + +```bash +npm ci +npx playwright install --with-deps chromium +npm run demos +npm run demos:preview +``` + +From inside this folder, `node record.js` and `node preview.js` also work. +All relative `--out` and preview paths are resolved against the repository root, +independently of the shell's current working directory. + +Open the local URL printed by the preview command. Generated files are in +`output/playwright/demos/`, which is ignored by Git. The recorder starts and stops +its own loopback HTTP servers and browser; an already running viewer is unnecessary. + +A full FFmpeg installation with `libvpx-vp9` is preferred (on Ubuntu/Debian: +`sudo apt-get install ffmpeg`; on macOS with Homebrew: `brew install ffmpeg`). +If it is absent, the pipeline automatically finds the FFmpeg bundled with the +installed Playwright browser. Depending on the pinned Playwright version, this +may produce VP8 WebM instead. Both are supported; the actual codec is reported. +Set `FFMPEG_PATH` to choose an executable explicitly. Set `VARRI_BROWSER_PATH` for +a custom Chromium executable. Binaries are never downloaded by the recorder. + +```bash +npm run demos -- --list +npm run demos -- --only move-selection +npm run demos -- --fps 20 --crf 32 --max-kib 350 --max-seconds 45 +npm run demos -- --out output/playwright/review-demos --headed +npm run demos:preview -- output/playwright/review-demos +``` + +Each invocation generates a gallery and manifest for exactly the selected scenes. +Existing unrelated files in the output directory are left in place. A failed +recording, size check, or playback check leaves diagnostic files in a temporary +`output/playwright/demo-build-*` directory and does not replace the last successful +output. Successful runs remove their temporary files. + +## Included scenes and deliverables + +| Scene ID | Task | +| --- | --- | +| `select-nodes` | Add and remove nodes with Ctrl/Command-click | +| `move-selection` | Select two nodes, drag them together, keep their new positions | +| `release-positions` | With Force layout enabled, open View controls and release a moved node | +| `reset-positions` | Move a node, reopen View controls, select Moved, Reset, then Undo | +| `rectangle-selection` | Drag a selection rectangle around several nodes | +| `rotate-selection` | Ctrl/Command-scroll to rotate selected nodes around the pointer | +| `place-text-annotation` | Add and position a text annotation | +| `import-fasta` | Load sequence and structure data from the FASTA dialog | +| `apply-probability-profile` | Apply a probability profile to nucleotide styling | +| `store-load-session` | Store the current viewer state and load it again | +| `export-png` | Export the annotated canvas as a PNG image | +| `add-region-highlight` | Add a region highlight from the annotation panel | +| `choose-example` | Load a complete example from the left-panel menu | +| `name-and-number-strand` | Name a strand and set biological numbering across skipped zero | +| `add-subsequence-highlight` | Highlight a strand range using its biological indices | +| `add-point-mutation` | Annotate a point mutation on the supplied structure | +| `highlight-rri-nucleotides` | Compare interaction-region and paired-nucleotide outlines | +| `highlight-rri-background` | Control generated region and paired-stack backgrounds | +| `linearize-rri-layout` | Create a stable horizontal interaction with Force layout off | + +The [left-panel council decision](left-panel-videos.md) explains the scope, +placement, fixtures, and sequential review requirements for the new walkthroughs. + +For each scene the output contains: + +- `.webm`: silent video, 960 × 640, 15 fps by default. +- `.png`: full-size poster, generated before the first action. +- `.vtt`: required timed English captions; instructions also appear in the video. +- `-review.png`: the last decoded video frame for visual review. + +`index.html`, `gallery.css`, and `gallery-player.js` provide the preview. +`manifest.json` records duration, exact byte count, frame size, codec, quality, +frame rate, captions, instruction/action timing, SHA-256, source revision, and +whether the checkout was dirty. +PNG review frames are QA artifacts; they do not need to be deployed. + +## Quality and verification + +The default video budget is 4096 KiB per clip and the duration limit is 45 seconds, allowing +reading pauses before each action. Use `--max-seconds` to set a stricter limit. +Exceeding either fails the build. The larger size budget preserves UI text when long reading holds are encoded +with the bundled VP8 encoder. Use `--max-kib` for a stricter delivery budget. +Defaults favor readable labels: VP9 uses CRF 32 +and constant quality; the VP8 fallback uses CRF 10 with a 1 Mbit/s target. +CRF scales differ between codecs. Higher CRF generally reduces file size at the +expense of clarity. Prefer shorter scenes before substantially increasing CRF. + +The pipeline checks selection counts, group displacement, stationary unselected +nodes, and reset/undo positions. Chromium then decodes every exported video from +start to finish, checks its dimensions and duration, and captures its final frame. +The preview is checked with reduced motion enabled. Visually review the clips +before embedding, particularly after changes to layout, controls, or fonts. + +Recording uses a fixed viewport, seeded randomness, and static layout. It captures +one frame for each pointer step and repeats frames for instructional pauses. This +avoids loading screens and gives explicit output timing even on slower machines. +Each frame waits until the viewer finishes any in-progress render, so action +effects and holds show the completed drawing. An intentionally cleared canvas +remains visible and is still recorded. +It is designed for discrete interactions, not real-time force-layout animation. +`capture.hold()` repeats a still frame; it does not record ongoing physics. Use a +separate real-time recording approach if a future scene needs to show convergence. +Fonts and browser/platform differences can still change pixels and encoded bytes. + +## Add a scene + +Create `recording/scenes/move-one-node.js` using the complete example below. +Each scene exports a unique filename-safe `id`, `title`, `description`, a nonempty +`placements` array, and `async run(capture, page)`. Optional fields are URL +`parameters`, `stage`, `zoom`, and `prepare(page)`. +Use lowercase hyphenated IDs such as `move-one-node`. + +```javascript +import assert from 'node:assert/strict'; + +export default { + id: 'move-one-node', + title: 'Move one node', + description: 'Drag an individual nucleotide to reposition it.', + placements: ['docs:additional-features'], + async run(capture, page) { + const target = page.locator('#rendering-canvas circle[node_type="nucleotide"]').nth(3); + const before = await target.boundingBox(); + await capture.caption('Drag a nucleotide to reposition it'); + await capture.hold(1); + await capture.drag(target, -90, -70); + const after = await target.boundingBox(); + assert.ok(Math.abs(after.x - before.x + 90) < 1); + assert.ok(Math.abs(after.y - before.y + 70) < 1); + await capture.hold(1.5); + }, +}; +``` + +Import this module in `scenes/index.js` and append it to the exported `scenes` +array. Run `npm run demos -- --only move-one-node`, watch the preview, and inspect +the decoded final frame. Run all scenes when shared helpers or framing change. +`placements` describes intended help locations, such as `canvas-help:move` or +`docs:additional-features`; these names are metadata, not DOM selectors. To display +the new demo, add `
` beside its written +instructions in the relevant guide or help component, then republish the complete +catalog after review. Registering a scene alone does not add a player to the UI. + +Use real mouse/keyboard events for demonstrated actions and assertions for their +results. Use `paste()` for complete FASTA records or multiline profile data to +avoid showing validation errors for partially typed records. Do not assign node positions or fake selection classes. +`capture.click(locator, 'Control')` +shows a modifier-click; omit the modifier for a regular click. +`capture.move({x, y}, seconds)` moves the visible pointer and actual mouse together. +`capture.caption(text)` opens a centered instruction card and updates the +transcript/VTT timeline. The next gesture automatically completes its reading +pause, clears the card, and holds the unobscured viewer briefly before acting. +`capture.clearCaption()` does this explicitly for a result-only card. Existing +`hold()` time counts toward the reading pause; `finish()` clears any final card +and holds the unobscured result. The poster is a title card, outside the timeline. +Every scene must produce at least one nonempty, single-line caption. Separate +caption changes with captured frames so their start times increase. + +The fixture and viewport are in `config.js`; readiness checks, zoom, and the +recording overlay are in `lib/capture.js`. Override the fixture for one scene with +`parameters: { sequence: '...', structure: '...' }`. The common readiness check +expects an explicitly supplied sequence and counts its real nucleotides. + +The framing is isolated in `lib/stage.css`. The default `stage: 'canvas'` shows +the actual canvas and manual position controls with centered instruction cards between action phases. +Use `stage: 'full-ui'` for input panels, `'dialog'` for input/annotation dialogs, +or `'export-dialog'` for PNG export. `zoom: false` disables the setup wheel zoom. +`async prepare(page)` runs after common framing, readiness, and zoom but before +the poster or capture; use it to open the relevant panel or prepare a fixture. +Shared `openPanel(page, selector)` and `ready(page)` helpers support that setup. +Scene-specific CSS can be added there when the stage profiles are insufficient. +If changing shared framing, review all scenes. When an action rerenders the +viewer, wait for its new state before the next frame. Instructions and visible +gestures belong in `run`, after setup finishes. + +| Capture helper | Effect | +| --- | --- | +| `caption(text)` | Show a centered card; enforce reading before the next action | +| `clearCaption()` | Complete reading and reveal the viewer without an action | +| `paste(locator, value)` | Insert a complete record in one input event with a Paste cue | +| `hold(seconds)` | Repeat the current frame for a reading pause | +| `move({ x, y }, seconds?)` | Move actual mouse and visible pointer together | +| `click(locator, modifier?, afterAction?)` | Approach and click; optionally hold `Control`/`Meta` and await the resulting render | +| `drag(locator, dx, dy, modifier?)` | Approach, press, drag by screen pixels, release | +| `dragBetween(start, destination, modifier?, seconds?)` | Drag between two screen points | +| `dragTo(locator, destination, modifier?)` | Drag an element to a screen point | +| `fill(locator, value)` | Focus a field and replace its text with keyboard input | +| `select(locator, value)` | Move to a select and set its value with input/change events | +| `wheel(locator, deltaY, modifier?)` | Move to an element and scroll, optionally holding a modifier | +| `press(key, modifier?, afterAction?)` | Send a keyboard action, then optionally await its render before the effect hold | + +For a new gesture, keep pointer feedback and frame capture together in a new +`Capture` method; a bare `page.click()` in a scene would omit its visible movement. +Waits for a render finish are separate from `hold()` and do not add video time. +For an Enter commit or checkbox click that rerenders the drawing, pass a changed-result readiness +callback as `press()`'s or `click()`'s third argument so the effect hold shows the completed +render instead of freezing an intermediate blank canvas. + +## Review and publish into help + +Record the complete set from a clean checkout, then review every clip, poster, +caption track, transcript, and decoded final frame before promoting it into the +served catalog. Human review is required; the publisher cannot verify that it +occurred. Stop the preview server after reviewing to run the following commands +in the same terminal, or use a second terminal: + +```bash +npm run demos +npm run demos:preview +npm run demos:publish +npm run demos:check-published +``` + +`record.js` writes generated previews under `output/playwright/`; `publish.js` +separately updates the tracked `doc/demos/` catalog. `demos:publish` requires a +clean checkout and matching recorded source revision, validates the recorded +asset checksums and metadata, and promotes the complete registered scene set. +It copies only `.webm`, poster `.png`, and `.vtt` deliverables, writes their +checksums and written transcripts to the production manifest, and excludes QA-only +review frames. `demos:check-published` verifies the catalog schema and asset hashes +and rejects missing, modified, duplicate, or orphaned assets. Neither check replaces +visual review of the instructions and demonstrated behavior. + +The viewer guide and canvas interaction help contain `data-help-demo` anchors. +`src/ui/help-videos.js` matches those anchors to the catalog. Hovering or focusing a +documentation control reveals its poster teaser; clicking or pressing Enter expands +the native player and attaches the WebM and captions lazily. The reader starts playback. + +Small video icons beside left-panel controls use `data-help-video` to open a muted +preview on hover or keyboard focus. Click or tap keeps it open with native controls, +optional captions, a transcript, and a download. The compact player uses the +recorded instruction cards by default; enabled captions appear above the cards. +Canvas action buttons use `data-help-preview` for repeating muted hover previews. +Reduced motion suppresses automatic playback, and starting a clip pauses other +videos. Escape dismisses previews; closing the containing panel stops playback. +Sources load only when their guide is used. + +For another integration, retain useful explanatory text beside each video: + +```html +
+ +
+ Ctrl/Command-click nodes to select them, then drag a selected node to move the group. +
+
+``` + +Style the video with `max-width: 100%; height: auto`. `preload="none"` defers video +downloads until requested; the poster supplies the initial image. Native controls +allow pausing, replaying, and keyboard access. The generated gallery starts only +on request, pauses other clips when a new one starts, and never loops. Avoid +automatically playing several help videos at once. For browsers outside your WebM +support targets, an additional MP4 source can be supplied. + +## GitHub Actions + +The **Generate interaction demos** workflow is manually triggered with +`workflow_dispatch`. It installs Chromium and a VP9-capable FFmpeg, runs the same +pipeline, and uploads previews and diagnostics as an artifact retained for 14 +days. It does not commit generated media, deploy Pages, or publish a release. +Select the branch containing the desired viewer changes when triggering it. + +References: [Playwright screenshots](https://playwright.dev/docs/screenshots), +[Google's VP9 encoding guidance](https://developers.google.com/media/vp9/settings/vod). diff --git a/recording/config.js b/recording/config.js new file mode 100644 index 0000000..dc2f2c2 --- /dev/null +++ b/recording/config.js @@ -0,0 +1,12 @@ +// Recording-only defaults. These do not change the production viewer. +export const viewport = { width: 960, height: 640 }; +export const defaultParameters = { + demoRecording: '1', + sequence: 'GGGAAACCC&GGGUUUCCC', + structure: '(((...(((&)))...)))', + forceLayout: '0', + highlighting: 'nothing', + backgroundhighlighting: 'nothing', + seqName1: 'RNA 1', + seqName2: 'RNA 2', +}; diff --git a/recording/left-panel-videos.md b/recording/left-panel-videos.md new file mode 100644 index 0000000..ce1e0bc --- /dev/null +++ b/recording/left-panel-videos.md @@ -0,0 +1,45 @@ +# Left-panel video council + +The UX, RNA-domain, and recording-QA agents reviewed the real controls, +documentation, existing recordings, and small input fixtures. They agreed on +seven new task-focused videos and reuse of five existing videos. + +| New video | Teaching point | Observable result | +| --- | --- | --- | +| Choose an example | Load a complete example configuration | Inputs, selection and annotated drawing change together | +| Name and number a strand | Commit a strand name and biological start index | Name changes; numbering crosses from -2 to 1, skipping zero | +| Add a subsequence highlight | Choose a strand, biological range and opacity | Highlight follows the chosen nucleotides and appears in the list | +| Annotate a point mutation | Add a replacement annotation at a biological position | Mutation notation and replacement label appear; supplied input is retained | +| Highlight RRI nucleotides | Compare whole interaction region with paired bases | Bulged example shows ten outlined nodes versus eight | +| Highlight the RRI background | Compare one region with separate paired stacks | Generated entries and polygons follow the chosen mode | +| Make the RRI horizontal | Enable a linear interaction layout with Force layout off | Paired rails align horizontally and remain stable | + +The sequence panel also reuses FASTA import and saved-session videos. The text, +region, and probability panels reuse text placement, manual paired regions, and +probability profiles. This avoids duplicate explanations of existing workflows. + +Each relevant control has a small, muted video icon. Hovering or focusing it opens +the matching player; click or tap keeps it open. Desktop buttons are transparent +24-pixel targets with 16-by-12-pixel symbols, and touch targets are larger. Players +provide native controls, optional captions, transcripts and downloads. Reduced +motion disables hover autoplay, and closing a panel stops and rewinds its videos. + +## Recording and review + +Implement and review the seven new scenes sequentially. A dedicated agent owns +each scene, checks the real outcome, and visually reviews its video, poster, +caption track and decoded frames. Shared presentation remains unchanged: +centered instructions, reading time before gestures, amber action feedback, +and no reading-instruction footer. + +Use small valid RNA fixtures and existing colors. Keep force layout off and +describe a settled result; repeated still frames do not show continuous physics. +The horizontal-layout clip uses the asymmetric nine-base fixture +`GGAGG&CCCC` / `((.((&))))`: its initial paired rails are visibly bent, unlike +the symmetric highlighting fixture, which already starts horizontal. +Mutation wording must describe an annotation, not a prediction or input edit. +Use the biological coordinates shown in the panel, including skipped zero. + +The new media must pass normal encoded playback, duration, size and source +provenance checks before joining the complete published catalog. Review reports +and galleries remain under ignored `output/playwright/left-panel-videos/`. diff --git a/recording/lib/captions.js b/recording/lib/captions.js new file mode 100644 index 0000000..16b922f --- /dev/null +++ b/recording/lib/captions.js @@ -0,0 +1,47 @@ +const plainText = value => typeof value === 'string' && value.trim() && !/[\r\n]/.test(value); +const escapeText = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +const timestamp = seconds => new Date(Math.round(seconds * 1000)).toISOString().slice(11, 23); + +export function validateCues(cues, duration) { + if (!Number.isFinite(duration) || duration <= 0 || !Array.isArray(cues) || !cues.length || + cues[0].start !== 0 || cues.some((cue, index) => !plainText(cue.text) || + !Number.isFinite(cue.start) || cue.start < 0 || cue.start >= duration || + (index && cue.start <= cues[index - 1].start))) { + throw new Error('Captions require ordered, nonempty, single-line cues spanning the clip duration.'); + } +} + +export function serializeCaptions(cues, duration) { + validateCues(cues, duration); + const blocks = cues.map((cue, index) => `${index + 1}\n${timestamp(cue.start)} --> ` + + `${timestamp(cues[index + 1]?.start ?? duration)}\n${escapeText(cue.text)}\n`); + return 'WEBVTT\n\n' + blocks.join('\n'); +} + +export function verifyCaptions(content, transcript, duration) { + const blocks = content.toString('utf8').trim().split(/\r?\n\r?\n/); + if (blocks.shift() !== 'WEBVTT' || blocks.length !== transcript.length) { + throw new Error('Caption track does not match the transcript.'); + } + const seconds = value => { + const match = /^(\d{2}):([0-5]\d):([0-5]\d)\.(\d{3})$/.exec(value); + if (!match) throw new Error('Invalid caption timestamp.'); + return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4]) / 1000; + }; + let previousEnd = 0; + blocks.forEach((block, index) => { + const [number, timing, text, ...extra] = block.split(/\r?\n/); + const times = timing?.split(' --> '); + if (number !== String(index + 1) || times?.length !== 2 || extra.length || + !plainText(text) || text !== escapeText(transcript[index])) { + throw new Error('Caption track does not match the transcript.'); + } + const start = seconds(times[0]); + const end = seconds(times[1]); + if (Math.abs(start - previousEnd) > 0.001 || end <= start || end > duration + 0.001) { + throw new Error('Caption timing must cover the clip without gaps or overlaps.'); + } + previousEnd = end; + }); + if (Math.abs(previousEnd - duration) > 0.001) throw new Error('Captions do not cover the complete clip.'); +} diff --git a/recording/lib/capture.js b/recording/lib/capture.js new file mode 100644 index 0000000..dddfd6b --- /dev/null +++ b/recording/lib/capture.js @@ -0,0 +1,313 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { viewport } from './runtime.js'; +import { defaultParameters } from '../config.js'; +import { serializeCaptions } from './captions.js'; +import { installPresentation, raisePresentation, showInstruction, hideInstruction, + showAction, hideAction, readingSeconds } from './presentation.js'; + +export async function prepareStage(page, origin, scene) { + const parameters = new URLSearchParams({ ...defaultParameters, ...scene.parameters }); + await page.goto(`${origin}/index.html?${parameters}`, { waitUntil: 'networkidle' }); + await page.evaluate(({ size, stage }) => { + document.documentElement.style.setProperty('--demo-width', `${size.width}px`); + document.documentElement.style.setProperty('--demo-height', `${size.height}px`); + document.body.dataset.demoStage = stage; + }, { size: viewport, stage: scene.stage || 'canvas' }); + await page.addStyleTag({ path: path.join(import.meta.dirname, 'stage.css') }); + await installPresentation(page, scene.title); + await page.waitForFunction(count => { + const canvas = document.querySelector('#rendering-canvas'); + return canvas?.style.visibility !== 'hidden' && + canvas?.querySelectorAll('circle[node_type="nucleotide"]').length === count; + }, parameters.get('sequence').replaceAll('&', '').length); + await page.evaluate(() => document.fonts.ready); + // Keep the diagram unobscured during setup. Scenes that demonstrate drawer + // actions reopen it with recorded pointer gestures after their instruction. + if (await page.locator('#viewControlsPanel').isVisible()) { + await page.locator('#viewControlsToggle').click(); + } + // Allow the real viewer's ResizeObserver to refit after framing, before capture. + await page.waitForTimeout(350); + if (scene.zoom !== false) { + // Zoom using the real viewer gesture, keeping labels legible at embed sizes. + const canvas = await page.locator('#rendering-canvas').boundingBox(); + await page.mouse.move(canvas.x + canvas.width / 2, canvas.y + canvas.height / 2); + await page.mouse.wheel(0, scene.stage && scene.stage !== 'canvas' ? -180 : -400); + await page.waitForTimeout(250); + } + await page.mouse.move(35, 110); +} + +export class Capture { + constructor(page, encoder, options, file) { + this.page = page; + this.fps = options.fps; + this.frames = 0; + this.cues = []; + this.timeline = []; + this.maxSeconds = options.maxSeconds ?? 45; + this.pendingCaption = null; + this.pointer = { x: 35, y: 110 }; + const args = ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', + '-c:v', 'mjpeg', '-r', String(this.fps), '-i', 'pipe:0', '-an', + '-c:v', encoder.encoder, '-b:v', encoder.codec === 'vp9' ? '0' : '1M', '-crf', String(options.crf), + '-deadline', 'good', '-cpu-used', '4', '-pix_fmt', 'yuv420p', + '-threads', '2', '-r', String(this.fps), file]; + this.process = spawn(encoder.executable, args, { stdio: ['pipe', 'ignore', 'pipe'] }); + this.errors = ''; + this.process.stderr.on('data', data => { this.errors = (this.errors + data).slice(-8000); }); + this.process.stdin.on('error', () => {}); + this.finished = new Promise((resolve, reject) => { + this.process.once('error', reject); + this.process.once('close', code => code === 0 ? resolve() : + reject(new Error(`FFmpeg failed (${code}): ${this.errors}`))); + }); + // Encoding may fail before the first screenshot has arrived. + this.finished.catch(() => {}); + } + + async write(image, count = 1) { + for (let index = 0; index < count; index++) { + if (this.process.exitCode !== null) { await this.finished; throw new Error('Encoder exited early'); } + await new Promise((resolve, reject) => this.process.stdin.write(image, error => error ? reject(error) : resolve())); + this.frames++; + if (this.frames > this.fps * this.maxSeconds) { + throw new Error(`A demo exceeded the ${this.maxSeconds}-second duration budget.`); + } + } + } + + async frame(count = 1) { + // UI commits hide the drawing while rendering. Wait before taking a still + // so an effect hold cannot repeat that transient blank canvas. + await this.page.waitForFunction(() => { + const canvas = document.getElementById('rendering-canvas'); + return canvas && canvas.style.visibility !== 'hidden'; + }); + await raisePresentation(this.page); + await this.write(await this.page.screenshot({ type: 'jpeg', quality: 95, + clip: { x: 0, y: 0, ...viewport } }), count); + } + + async hold(seconds) { await this.frame(Math.max(1, Math.round(seconds * this.fps))); } + + async poster(text) { + await showInstruction(this.page, text, 0); + await raisePresentation(this.page); + } + + async caption(text) { + if (this.pendingCaption) await this.clearCaption(); + const cue = { start: this.frames / this.fps, text }; + this.cues.push(cue); + this.pendingCaption = cue; + await showInstruction(this.page, text, this.cues.length); + } + + async clearCaption() { + if (!this.pendingCaption) return; + const cue = this.pendingCaption; + const remaining = readingSeconds(cue.text) - (this.frames / this.fps - cue.start); + if (remaining > 0) await this.hold(remaining); + this.timeline.push({ type: 'instruction', start: cue.start, + end: this.frames / this.fps, text: cue.text }); + this.pendingCaption = null; + await hideInstruction(this.page); + await this.hold(0.27); + } + + async pulse(text, box = null, seconds = 0.4) { + const steps = Math.max(2, Math.round(seconds * this.fps)); + for (let step = 0; step < steps; step++) { + await showAction(this.page, this.pointer, text, box, step / steps); + await this.frame(); + } + } + + markAction(type, text) { + this.timeline.push({ type, start: this.frames / this.fps, text, + x: this.pointer.x, y: this.pointer.y }); + } + + async move(destination, seconds = 0.55) { + await this.clearCaption(); + const from = this.pointer; + const steps = Math.max(1, Math.round(seconds * this.fps)); + for (let step = 1; step <= steps; step++) { + const fraction = step / steps; + const eased = fraction * fraction * (3 - 2 * fraction); + const next = { x: from.x + (destination.x - from.x) * eased, + y: from.y + (destination.y - from.y) * eased }; + if (next.x < 0 || next.y < 0 || next.x > viewport.width - 28 || next.y > viewport.height - 34) { + throw new Error('Pointer left the recorded frame; adjust the scene.'); + } + await this.page.mouse.move(next.x, next.y); + await this.page.locator('[data-demo-pointer]').evaluate((element, point) => { + element.style.transform = `translate(${point.x}px, ${point.y}px)`; + }, next); + if (this.dragLabel) await showAction(this.page, next, this.dragLabel); + await this.frame(); + } + this.pointer = destination; + } + + async pressed(value) { + await this.page.locator('[data-demo-pointer]').evaluate((element, pressed) => + element.toggleAttribute('data-pressed', pressed), value); + } + + async click(locator, modifier, afterAction) { + await this.clearCaption(); + const box = await locator.boundingBox(); + await this.move(await center(locator)); + const label = modifier ? 'Ctrl / ⌘ + click' : 'Click'; + this.markAction('click', label); + if (modifier) await this.page.keyboard.down(modifier); + try { + await this.page.mouse.down(); + await this.pressed(true); + await this.pulse(label, box); + await this.page.mouse.up(); + await this.pressed(false); + } finally { if (modifier) await this.page.keyboard.up(modifier); } + if (afterAction) await afterAction(); + await this.pulse(label, box, 0.27); + await hideAction(this.page); + await this.hold(0.4); + } + + async dragBetween(start, destination, modifier, seconds = 1.3) { + await this.clearCaption(); + if (modifier) await this.page.keyboard.down(modifier); + try { + await this.move(start); + this.dragLabel = modifier ? 'Ctrl / ⌘ + drag' : 'Drag'; + this.markAction('drag', this.dragLabel); + await this.page.mouse.down(); + await this.pressed(true); + await this.pulse(this.dragLabel, null, 0.27); + await this.move(destination, seconds); + await this.page.mouse.up(); + await this.pressed(false); + await this.pulse('Release', null, 0.27); + } finally { + this.dragLabel = null; + if (modifier) await this.page.keyboard.up(modifier); + await hideAction(this.page); + } + await this.hold(0.5); + } + + async drag(locator, dx, dy, modifier) { + const start = await center(locator); + await this.dragBetween(start, { x: start.x + dx, y: start.y + dy }, modifier); + } + + async dragTo(locator, destination, modifier) { + await this.dragBetween(await center(locator), destination, modifier); + } + + async fill(locator, value) { + await this.click(locator); + this.markAction('type', 'Type'); + await showAction(this.page, this.pointer, 'Type', await locator.boundingBox()); + await this.page.keyboard.press('Control+A'); + await this.page.keyboard.press('Backspace'); + const chunks = String(value).match(/[\s\S]{1,24}/g) || ['']; + for (const chunk of chunks) { + await this.page.keyboard.insertText(chunk); + await this.frame(Math.max(1, Math.round(this.fps * 0.12))); + } + await this.hold(0.35); + await hideAction(this.page); + } + + async paste(locator, value) { + await this.click(locator); + this.markAction('paste', 'Paste'); + await showAction(this.page, this.pointer, 'Paste', await locator.boundingBox()); + await this.page.keyboard.press('Control+A'); + // Insert the complete clipboard-sized value in one real input event. Partial + // FASTA/profile records would briefly show validation errors while typing. + await this.page.keyboard.insertText(String(value)); + await this.hold(0.6); + await hideAction(this.page); + } + + async select(locator, value) { + await this.clearCaption(); + await this.move(await center(locator)); + this.markAction('select', 'Choose option'); + await this.pulse('Choose option', await locator.boundingBox()); + const index = await locator.evaluate((element, value) => + [...element.options].findIndex(option => option.value === value), value); + if (index < 0) throw new Error(`Missing select option: ${value}`); + await locator.focus(); + await this.page.keyboard.press('Home'); + for (let step = 0; step < index; step++) await this.page.keyboard.press('ArrowDown'); + await this.page.keyboard.press('Enter'); + // Chromium can open the native popup on Enter after closed-select arrow + // changes. Dismiss it so it cannot cover the next instruction card. + await this.page.keyboard.press('Escape'); + await this.hold(0.6); + await hideAction(this.page); + } + + async wheel(locator, deltaY, modifier) { + await this.clearCaption(); + await this.move(await center(locator)); + const label = modifier ? 'Ctrl / ⌘ + scroll' : 'Scroll'; + this.markAction('scroll', label); + if (modifier) await this.page.keyboard.down(modifier); + try { + for (let step = 0; step < 6; step++) { + await showAction(this.page, this.pointer, label, null, step / 6); + await this.page.mouse.wheel(0, deltaY / 6); + await this.page.waitForTimeout(35); + await this.hold(0.13); + } + } finally { if (modifier) await this.page.keyboard.up(modifier); } + await hideAction(this.page); + await this.hold(0.4); + } + + async press(key, modifier, afterAction) { + await this.clearCaption(); + const label = modifier ? `${modifier} + ${key}` : key; + this.markAction('key', label); + await showAction(this.page, this.pointer, label); + if (modifier) await this.page.keyboard.down(modifier); + try { await this.page.keyboard.press(key); } + finally { if (modifier) await this.page.keyboard.up(modifier); } + if (afterAction) await afterAction(); + await this.hold(0.5); + await hideAction(this.page); + } + + async finish() { + await this.clearCaption(); + await hideAction(this.page); + await this.hold(0.8); + this.process.stdin.end(); + await this.finished; + return this.frames / this.fps; + } + + async abort() { + this.process.stdin.destroy(); + this.process.kill(); + await this.finished.catch(() => {}); + } +} + +export async function center(locator) { + const box = await locator.boundingBox(); + if (!box) throw new Error(`Missing visible target: ${locator}`); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +export async function writeCaptions(file, cues, duration) { + await fs.writeFile(file, serializeCaptions(cues, duration)); +} diff --git a/recording/lib/catalog.js b/recording/lib/catalog.js new file mode 100644 index 0000000..b47d8ba --- /dev/null +++ b/recording/lib/catalog.js @@ -0,0 +1,114 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { serializeCaptions, validateCues, verifyCaptions } from './captions.js'; +import { viewport } from '../config.js'; + +const digest = content => createHash('sha256').update(content).digest('hex'); +const nonempty = value => typeof value === 'string' && value.trim().length > 0; +const assetKinds = { video: ['webm', 'video/webm'], poster: ['png', 'image/png'], captions: ['vtt', 'text/vtt'] }; +const maxAssetBytes = 10 * 1024 * 1024; + +function validateIdentity(manifest, scenes, revisionField) { + if (!Array.isArray(manifest.clips) || manifest.clips.length !== scenes.length || + new Set(manifest.clips.map(clip => clip.id)).size !== scenes.length || + scenes.some(scene => !manifest.clips.some(clip => clip.id === scene.id))) { + throw new Error('Demo manifest must contain every registered scene exactly once.'); + } + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(manifest[revisionField]) || + !nonempty(manifest.generatedAt) || !Number.isFinite(Date.parse(manifest.generatedAt))) { + throw new Error('Demo manifest requires a source revision and valid generation time.'); + } +} + +function validateClip(clip, scene) { + const kind = scene.stage && scene.stage !== 'canvas' ? 'walkthrough' : 'micro'; + if (clip.title !== scene.title || clip.description !== scene.description || clip.kind !== kind || + JSON.stringify(clip.placements) !== JSON.stringify(scene.placements) || + clip.width !== viewport.width || clip.height !== viewport.height || + !Number.isFinite(clip.duration) || clip.duration <= 0 || clip.duration > 120 || + !Number.isInteger(clip.fps) || clip.fps < 10 || clip.fps > 30 || !['vp8', 'vp9'].includes(clip.codec)) { + throw new Error(`${clip.id}: invalid or stale demo metadata.`); + } +} + +async function readAsset(directory, id, kind, asset) { + const [extension, mime] = assetKinds[kind]; + if (!asset || asset.file !== `${id}.${extension}` || asset.mime !== mime || + !Number.isInteger(asset.bytes) || asset.bytes <= 0 || asset.bytes > maxAssetBytes || + !/^[a-f0-9]{64}$/.test(asset.sha256)) { + throw new Error(`${id}: invalid ${kind} asset metadata.`); + } + const location = path.join(directory, asset.file); + if (!(await fs.lstat(location)).isFile()) throw new Error(`${id}: ${kind} must be a regular file.`); + const content = await fs.readFile(location); + if (content.length !== asset.bytes || digest(content) !== asset.sha256) { + throw new Error(`${id}: ${asset.file} does not match its recorded checksum and byte count.`); + } + return content; +} + +export async function verifyPublishedCatalog(manifest, directory, scenes) { + if (manifest.schemaVersion !== 1 || manifest.locale !== 'en') { + throw new Error('Published demo manifest must use schemaVersion 1 and English captions.'); + } + validateIdentity(manifest, scenes, 'sourceRevision'); + let aggregate = 0; + for (const clip of manifest.clips) { + validateClip(clip, scenes.find(scene => scene.id === clip.id)); + if (!Array.isArray(clip.transcript) || !clip.transcript.length || + clip.transcript.some(text => !nonempty(text) || /[\r\n]/.test(text)) || + !clip.assets || Object.keys(clip.assets).sort().join(',') !== 'captions,poster,video') { + throw new Error(`${clip.id}: required assets or transcript are missing.`); + } + for (const kind of Object.keys(assetKinds)) { + const content = await readAsset(directory, clip.id, kind, clip.assets[kind]); + aggregate += content.length; + if (kind === 'captions') verifyCaptions(content, clip.transcript, clip.duration); + } + } + if (aggregate !== manifest.aggregateBytes) throw new Error('Published aggregate byte count is incorrect.'); +} + +export async function buildPublishedCatalog(generated, directory, state, scenes) { + validateIdentity(generated, scenes, 'revision'); + if (state.dirty || generated.dirty !== false || generated.revision !== state.revision) { + throw new Error('Publish requires a clean checkout and demos freshly recorded from HEAD.'); + } + if (!Number.isInteger(generated.sizeBudgetBytes) || generated.sizeBudgetBytes <= 0 || + generated.sizeBudgetBytes > maxAssetBytes) throw new Error('Invalid recorded video size budget.'); + const durationBudget = generated.durationBudgetSeconds ?? 20; + if (!Number.isInteger(durationBudget) || durationBudget < 1 || durationBudget > 120) { + throw new Error('Invalid recorded duration budget.'); + } + const clips = []; + let aggregateBytes = 0; + let aggregateWebmBytes = 0; + for (const clip of generated.clips) { + const scene = scenes.find(item => item.id === clip.id); + validateClip(clip, scene); + validateCues(clip.cues, clip.duration); + if (clip.duration > durationBudget) throw new Error(`${clip.id}: recorded duration exceeds its budget.`); + const assets = { + video: { file: clip.file, mime: 'video/webm', bytes: clip.bytes, sha256: clip.sha256 }, + poster: { file: clip.poster, mime: 'image/png', bytes: clip.posterBytes, sha256: clip.posterSha256 }, + captions: { file: clip.captions, mime: 'text/vtt', bytes: clip.captionsBytes, sha256: clip.captionsSha256 }, + }; + if (clip.bytes > generated.sizeBudgetBytes) throw new Error(`${clip.id}: recorded video exceeds its budget.`); + for (const kind of Object.keys(assetKinds)) { + const content = await readAsset(directory, clip.id, kind, assets[kind]); + if (kind === 'captions' && content.toString('utf8') !== serializeCaptions(clip.cues, clip.duration)) { + throw new Error(`${clip.id}: recorded captions do not match their cue timeline.`); + } + aggregateBytes += content.length; + } + aggregateWebmBytes += clip.bytes; + clips.push({ id: scene.id, title: scene.title, description: scene.description, + kind: clip.kind, placements: scene.placements, width: clip.width, height: clip.height, + duration: clip.duration, fps: clip.fps, codec: clip.codec, + transcript: clip.cues.map(cue => cue.text), assets }); + } + if (aggregateWebmBytes !== generated.aggregateWebmBytes) throw new Error('Recorded aggregate byte count is incorrect.'); + return { schemaVersion: 1, sourceRevision: state.revision, + generatedAt: generated.generatedAt, locale: 'en', aggregateBytes, clips }; +} diff --git a/recording/lib/gallery-player.js b/recording/lib/gallery-player.js new file mode 100644 index 0000000..9a26c2d --- /dev/null +++ b/recording/lib/gallery-player.js @@ -0,0 +1,5 @@ +// Explicit playback avoids unsolicited motion and downloads. +const videos = [...document.querySelectorAll('video')]; +videos.forEach(video => video.addEventListener('play', () => { + videos.filter(other => other !== video).forEach(other => other.pause()); +})); diff --git a/recording/lib/gallery.css b/recording/lib/gallery.css new file mode 100644 index 0000000..3b0efec --- /dev/null +++ b/recording/lib/gallery.css @@ -0,0 +1,11 @@ +* { box-sizing: border-box; } +body { margin: 0; background: #f3f6fa; color: #163449; font: 16px/1.6 system-ui, sans-serif; } +main { max-width: 1008px; padding: 32px 24px 60px; margin: auto; } +h1 { font-size: 32px; letter-spacing: -.7px; margin: 0; } +h2 { font-size: 23px; margin: 0; } +article { background: white; margin: 32px 0; padding: 24px; border: 1px solid #d6e0e7; border-radius: 12px; } +video { display: block; width: 100%; height: auto; border-radius: 8px; background: #f3f6fa; } +.meta { color: #536b7c; margin: 4px 0 18px; font-size: 14px; } +a { color: #155b87; text-underline-offset: 3px; } +summary { cursor: pointer; } +@media (max-width: 600px) { main { padding: 20px 12px; } article { padding: 12px; } } diff --git a/recording/lib/gallery.js b/recording/lib/gallery.js new file mode 100644 index 0000000..441329a --- /dev/null +++ b/recording/lib/gallery.js @@ -0,0 +1,32 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const escape = value => String(value).replace(/[&<>"']/g, character => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); + +export async function writeGallery(directory, clips) { + const cards = clips.map(clip => `
+

${escape(clip.title)}

+

${clip.duration.toFixed(1)} seconds · ${(clip.bytes / 1024).toFixed(1)} KiB · ${clip.codec.toUpperCase()} · ${clip.fps} fps

+ +

${escape(clip.description)}

+
Step-by-step transcript
    ${clip.cues.map(cue => `
  1. ${escape(cue.text)}
  2. `).join('')}
+

WebM · Poster · Captions

+
`).join('\n'); + await fs.writeFile(path.join(directory, 'index.html'), ` + +vaRRI interaction demos +

vaRRI interaction demos

+

Short recordings of the real viewer. Press Play to watch a clip.

+${cards} +

Generation and size report

+
\n`); + for (const file of ['gallery.css', 'gallery-player.js']) { + await fs.copyFile(path.join(import.meta.dirname, file), path.join(directory, file)); + } +} diff --git a/recording/lib/presentation.js b/recording/lib/presentation.js new file mode 100644 index 0000000..31cc9a3 --- /dev/null +++ b/recording/lib/presentation.js @@ -0,0 +1,74 @@ +// Recording overlays are separate from the viewer, including above modal dialogs. +export async function installPresentation(page, title) { + await page.evaluate(title => { + const layer = document.createElement('div'); + layer.dataset.demoLayer = ''; + layer.setAttribute('popover', 'manual'); + layer.innerHTML = ` +
+
`; + layer.dataset.title = title; + layer.querySelector('[data-demo-pointer]').style.transform = 'translate(35px, 110px)'; + document.body.append(layer); + layer.showPopover(); + }, title); +} + +export async function raisePresentation(page) { + await page.locator('[data-demo-layer]').evaluate(layer => { + // A newly opened dialog otherwise sits above an existing popover. + if (layer.matches(':popover-open')) layer.hidePopover(); + layer.showPopover(); + }); +} + +export async function showInstruction(page, text, step) { + await page.locator('[data-demo-layer]').evaluate((layer, { text, step }) => { + layer.querySelector('[data-demo-kicker]').textContent = step + ? `VIDEO GUIDE · STEP ${step}` : `VIDEO GUIDE · ${layer.dataset.title}`; + layer.querySelector('[data-demo-caption]').textContent = text; + layer.querySelector('[data-demo-heading]').hidden = false; + layer.querySelector('[data-demo-pointer]').hidden = true; + }, { text, step }); +} + +export async function hideInstruction(page) { + await page.locator('[data-demo-layer]').evaluate(layer => { + layer.querySelector('[data-demo-heading]').hidden = true; + layer.querySelector('[data-demo-pointer]').hidden = false; + }); +} + +export async function showAction(page, point, text, box = null, progress = 0) { + await page.locator('[data-demo-layer]').evaluate((layer, state) => { + const { point, text, box, progress } = state; + const ring = layer.querySelector('[data-demo-ring]'); + ring.hidden = false; + const diameter = 30 + progress * 44; + Object.assign(ring.style, { left: `${point.x - diameter / 2}px`, + top: `${point.y - diameter / 2}px`, width: `${diameter}px`, height: `${diameter}px`, + opacity: String(1 - progress * 0.6) }); + const label = layer.querySelector('[data-demo-action]'); + label.hidden = false; + label.textContent = text; + label.style.left = `${Math.max(12, Math.min(point.x + 24, innerWidth - 250))}px`; + label.style.top = `${point.y > innerHeight - 85 ? point.y - 54 : point.y + 32}px`; + const focus = layer.querySelector('[data-demo-focus]'); + focus.hidden = !box; + if (box) Object.assign(focus.style, { left: `${box.x - 5}px`, top: `${box.y - 5}px`, + width: `${box.width + 10}px`, height: `${box.height + 10}px` }); + }, { point, text, box, progress }); +} + +export async function hideAction(page) { + await page.locator('[data-demo-layer]').evaluate(layer => { + for (const selector of ['[data-demo-ring]', '[data-demo-action]', '[data-demo-focus]']) { + layer.querySelector(selector).hidden = true; + } + }); +} + +export const readingSeconds = text => Math.max(2.4, 0.45 + text.trim().split(/\s+/).length / 3.2); diff --git a/recording/lib/runtime.js b/recording/lib/runtime.js new file mode 100644 index 0000000..1b921ce --- /dev/null +++ b/recording/lib/runtime.js @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import http from 'node:http'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { chromium } from 'playwright'; + +export const root = path.resolve(import.meta.dirname, '../..'); +export { viewport } from '../config.js'; + +// Prefer a full FFmpeg installation. Playwright's small bundled encoder also +// works, so recording does not require adding any npm runtime dependencies. +export async function findEncoder() { + const candidates = process.env.FFMPEG_PATH ? [process.env.FFMPEG_PATH] : ['ffmpeg']; + if (!process.env.FFMPEG_PATH) { + const directory = path.dirname(fileURLToPath(import.meta.resolve('playwright-core/package.json'))); + const { browsers } = JSON.parse(await fs.readFile(path.join(directory, 'browsers.json'), 'utf8')); + const revision = browsers.find(browser => browser.name === 'ffmpeg').revision; + const name = { linux: 'ffmpeg-linux', darwin: 'ffmpeg-mac', win32: 'ffmpeg-win64.exe' }[process.platform]; + for (let parent = path.dirname(chromium.executablePath()); parent !== path.dirname(parent); + parent = path.dirname(parent)) { + if (name) candidates.push(path.join(parent, `ffmpeg-${revision}`, name)); + } + } + for (const executable of candidates) { + const result = spawnSync(executable, ['-hide_banner', '-encoders'], { encoding: 'utf8' }); + if (result.status !== 0) continue; + const encoders = result.stdout; + if (encoders.includes('libvpx-vp9')) return { executable, codec: 'vp9', encoder: 'libvpx-vp9' }; + if (/\blibvpx\s/.test(encoders)) return { executable, codec: 'vp8', encoder: 'libvpx' }; + } + throw new Error('No WebM encoder found. Install FFmpeg with libvpx, set FFMPEG_PATH, or run npx playwright install chromium.'); +} + +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', + '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', + '.webm': 'video/webm', '.vtt': 'text/vtt' }; + +export async function serve(directory) { + const base = await fs.realpath(directory); + const server = http.createServer(async (request, response) => { + try { + let pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + if (pathname.endsWith('/')) pathname += 'index.html'; + const target = await fs.realpath(path.resolve(base, '.' + pathname)); + if (!target.startsWith(base + path.sep)) { response.writeHead(403).end(); return; } + const content = await fs.readFile(target); + const headers = { 'Content-Type': mime[path.extname(target)] || 'application/octet-stream', + 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' }; + if (request.headers.range) { + const match = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range); + const start = match?.[1] ? Number(match[1]) : Math.max(0, content.length - Number(match?.[2])); + const end = match?.[1] && match[2] ? Math.min(Number(match[2]), content.length - 1) : content.length - 1; + if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || start > end || start >= content.length) { + response.writeHead(416, { ...headers, 'Content-Range': `bytes */${content.length}` }).end(); + return; + } + response.writeHead(206, { ...headers, 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${content.length}` }); + response.end(content.subarray(start, end + 1)); + } else { + response.writeHead(200, { ...headers, 'Content-Length': content.length }); + response.end(content); + } + } catch { response.writeHead(404).end('Not found'); } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + return { origin: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }) }; +} diff --git a/recording/lib/source-state.js b/recording/lib/source-state.js new file mode 100644 index 0000000..3341214 --- /dev/null +++ b/recording/lib/source-state.js @@ -0,0 +1,21 @@ +import { spawnSync } from 'node:child_process'; + +export function readSourceState(directory, run = spawnSync) { + const git = args => { + const result = run('git', args, { cwd: directory, encoding: 'utf8' }); + if (result.error || result.status !== 0 || typeof result.stdout !== 'string') { + throw new Error(`Cannot verify recording source: git ${args.join(' ')} failed.`); + } + return result.stdout.trim(); + }; + const revision = git(['rev-parse', 'HEAD']); + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(revision)) throw new Error('Cannot verify recording source revision.'); + return { revision, dirty: Boolean(git(['status', '--porcelain', '--untracked-files=all'])) }; +} + +export function recordingSourceState(before, after) { + if (before.revision !== after.revision) { + throw new Error('Source revision changed during recording; record again from one revision.'); + } + return { revision: before.revision, dirty: before.dirty || after.dirty }; +} diff --git a/recording/lib/stage.css b/recording/lib/stage.css new file mode 100644 index 0000000..782d960 --- /dev/null +++ b/recording/lib/stage.css @@ -0,0 +1,92 @@ +/* Recording-only framing. The viewer and its controls remain the actual app. */ +html, body { overflow: hidden !important; } +body[data-demo-stage="canvas"] .result-panel { + position: fixed !important; + inset: 0 !important; + width: var(--demo-width) !important; + max-width: none !important; + height: var(--demo-height) !important; + padding: 20px !important; + box-sizing: border-box; + background: #f3f6fa; + z-index: 10000; + resize: none !important; + overflow: hidden !important; +} +body[data-demo-stage="canvas"] .canvas-workspace { margin-top: 70px; } +body[data-demo-stage="canvas"] .result-panel > .export-bar, +body[data-demo-stage="canvas"] #rendering-caption { display: none !important; } + +body[data-demo-stage]:not([data-demo-stage="canvas"]) header, +body[data-demo-stage]:not([data-demo-stage="canvas"]) footer { display: none !important; } +body[data-demo-stage]:not([data-demo-stage="canvas"]) main { + position: fixed !important; + inset: 70px 0 0 !important; + width: var(--demo-width) !important; + height: calc(var(--demo-height) - 70px) !important; + max-width: none !important; + padding: 10px 16px 14px !important; + grid-template-columns: 340px minmax(0, 1fr) !important; + gap: 14px !important; + overflow: hidden !important; +} +body[data-demo-stage]:not([data-demo-stage="canvas"]) .controls-column { + height: 100% !important; + max-height: none !important; + overflow: auto !important; +} +body[data-demo-stage]:not([data-demo-stage="canvas"]) .result-panel { + width: 100% !important; + max-width: none !important; + height: 100% !important; + min-height: 0 !important; + resize: none !important; + overflow: hidden !important; +} +body[data-demo-stage="dialog"] dialog.app-dialog, +body[data-demo-stage="export-dialog"] dialog.app-dialog { + max-height: calc(var(--demo-height) - 92px) !important; +} +body[data-demo-stage="dialog"] #fastaDialog { + left: 140px !important; + top: 78px !important; + width: 680px !important; + max-width: calc(var(--demo-width) - 280px) !important; + overflow: auto !important; +} +body[data-demo-stage="dialog"] #fastaInput { min-width: 0 !important; height: 112px !important; } +body[data-demo-stage="dialog"] #fastaSequence, +body[data-demo-stage="dialog"] #fastaStructure { height: 50px !important; } +*, *::before, *::after { animation: none !important; transition: none !important; } +[data-demo-layer] { + position: fixed; inset: 0; width: 100vw; height: 100vh; + max-width: none; max-height: none; margin: 0; padding: 0; border: 0; + overflow: hidden; background: transparent; pointer-events: none; + font-family: system-ui, sans-serif; +} +[data-demo-layer] [hidden] { display: none !important; } +[data-demo-heading] { + position: absolute; inset: 0; display: grid; grid-template-rows: 1fr auto 1fr; + justify-items: center; align-items: center; gap: 26px; padding: 70px; + box-sizing: border-box; text-align: center; background: #102333f5; color: white; +} +[data-demo-kicker] { align-self: end; color: #f6c453; font-size: 15px; font-weight: 750; letter-spacing: 3px; } +[data-demo-caption] { max-width: 760px; font-size: 36px; line-height: 1.3; font-weight: 650; } +[data-demo-pointer] { + position: absolute; left: 0; top: 0; width: 28px; height: 34px; + filter: drop-shadow(0 1px 2px #0007); +} +[data-demo-pointer][data-pressed] path { fill: #fbbf24; } +[data-demo-ring] { + position: absolute; box-sizing: border-box; border: 4px solid #ed9b00; + border-radius: 50%; background: #ffc84526; box-shadow: 0 0 0 2px #fff; +} +[data-demo-focus] { + position: absolute; box-sizing: border-box; border: 3px solid #ed9b00; + border-radius: 7px; box-shadow: 0 0 0 2px #fff; +} +[data-demo-action] { + position: absolute; padding: 7px 12px; border: 2px solid #fff; + border-radius: 6px; color: #152536; background: #ffd36b; + font-size: 16px; font-weight: 750; line-height: 1.2; +} diff --git a/recording/lib/verify.js b/recording/lib/verify.js new file mode 100644 index 0000000..5b9e3c8 --- /dev/null +++ b/recording/lib/verify.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { serve, viewport } from './runtime.js'; + +/** Decode each delivered file in Chromium, including its last frame. */ +export async function verifyVideos(browser, directory, clips) { + const server = await serve(directory); + const context = await browser.newContext({ viewport, reducedMotion: 'reduce' }); + try { + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(server.origin); + for (const clip of clips) { + const video = page.locator('video').filter({ has: page.locator(`source[src="${clip.id}.webm"]`) }); + const metadata = await video.evaluate(async element => { + element.preload = 'auto'; + element.load(); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Video metadata timed out')), 10000); + element.addEventListener('loadedmetadata', () => { clearTimeout(timeout); resolve(); }, { once: true }); + element.addEventListener('error', () => { clearTimeout(timeout); reject(new Error('Video could not load')); }, { once: true }); + }); + return { width: element.videoWidth, height: element.videoHeight, duration: element.duration, loop: element.loop }; + }); + assert.equal(metadata.width, clip.width); + assert.equal(metadata.height, clip.height); + assert.ok(Math.abs(metadata.duration - clip.duration) < 0.15, `${clip.id}: incorrect encoded duration`); + assert.equal(metadata.loop, false, 'Reduced motion disables looping'); + await video.evaluate(async element => { + element.playbackRate = 4; + await element.play(); + }); + await page.waitForFunction(id => { + const element = document.querySelector(`source[src="${id}.webm"]`).parentElement; + if (element.error) throw new Error(element.error.message); + return element.ended; + }, clip.id, { timeout: Math.max(15000, clip.duration * 1000 / 4 + 10000) }); + await video.evaluate((element, width) => { + element.controls = false; + element.style.width = `${width}px`; + element.style.maxWidth = 'none'; + }, clip.width); + await video.screenshot({ path: path.join(directory, `${clip.id}-review.png`) }); + console.log(` Verified playback: ${clip.id}`); + } + assert.deepEqual(errors, [], 'Preview page has no JavaScript errors'); + } finally { + await context.close(); + await server.close(); + } +} diff --git a/recording/preview.js b/recording/preview.js new file mode 100644 index 0000000..94ab980 --- /dev/null +++ b/recording/preview.js @@ -0,0 +1,13 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { root, serve } from './lib/runtime.js'; + +const directory = path.resolve(root, process.argv[2] || 'output/playwright/demos'); +await fs.access(path.join(directory, 'index.html')).catch(() => { + throw new Error('No demo preview found. Run npm run demos first.'); +}); +const server = await serve(directory); +console.log(`Demo preview: ${server.origin}\nPress Ctrl+C to stop.`); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, async () => { await server.close(); process.exit(0); }); +} diff --git a/recording/publish.js b/recording/publish.js new file mode 100644 index 0000000..029fe21 --- /dev/null +++ b/recording/publish.js @@ -0,0 +1,54 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { root } from './lib/runtime.js'; +import { scenes } from './scenes/index.js'; +import { buildPublishedCatalog, verifyPublishedCatalog } from './lib/catalog.js'; +import { readSourceState } from './lib/source-state.js'; + +const published = path.join(root, 'doc/demos'); + +async function checkPublished() { + const manifest = JSON.parse(await fs.readFile(path.join(published, 'manifest.json'), 'utf8')); + await verifyPublishedCatalog(manifest, published, scenes); + const expected = new Set(['manifest.json', ...manifest.clips.flatMap(clip => + Object.values(clip.assets).map(asset => asset.file))]); + const unexpected = (await fs.readdir(published)).filter(file => !expected.has(file)); + if (unexpected.length) throw new Error(`Unexpected published demo assets: ${unexpected.join(', ')}`); + console.log(`Published demos passed: ${manifest.clips.length} clips, ${(manifest.aggregateBytes / 1024).toFixed(1)} KiB.`); +} + +async function publish(source) { + const generated = JSON.parse(await fs.readFile(path.join(source, 'manifest.json'), 'utf8')); + const state = readSourceState(root); + const manifest = await buildPublishedCatalog(generated, source, state, scenes); + const next = await fs.mkdtemp(path.join(root, 'doc', '.demos-next-')); + let replaced = false; + const backup = path.join(next, 'previous'); + const install = path.join(next, 'install'); + try { + await fs.mkdir(install); + for (const clip of manifest.clips) { + for (const asset of Object.values(clip.assets)) { + await fs.copyFile(path.join(source, asset.file), path.join(install, asset.file)); + } + } + await fs.writeFile(path.join(install, 'manifest.json'), JSON.stringify(manifest) + '\n'); + await verifyPublishedCatalog(manifest, install, scenes); + await fs.rename(published, backup).catch(error => { if (error.code !== 'ENOENT') throw error; }); + await fs.rename(install, published); + replaced = true; + } catch (error) { + await fs.rename(backup, published).catch(() => {}); + throw error; + } finally { + // On an unsuccessful restore preserve the backup for recovery. + const hasBackup = await fs.stat(backup).then(() => true, () => false); + if (replaced || !hasBackup) await fs.rm(next, { recursive: true, force: true }); + } + console.log(`Published ${manifest.clips.length} reviewed demos to ${published}.`); +} + +const { values } = parseArgs({ options: { check: { type: 'boolean' }, from: { type: 'string' } } }); +if (values.check) await checkPublished(); +else await publish(path.resolve(root, values.from || 'output/playwright/demos')); diff --git a/recording/record.js b/recording/record.js new file mode 100644 index 0000000..9aedf8d --- /dev/null +++ b/recording/record.js @@ -0,0 +1,142 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { parseArgs } from 'node:util'; +import { chromium } from 'playwright'; +import { root, viewport, serve, findEncoder } from './lib/runtime.js'; +import { prepareStage, Capture, writeCaptions } from './lib/capture.js'; +import { scenes } from './scenes/index.js'; +import { writeGallery } from './lib/gallery.js'; +import { verifyVideos } from './lib/verify.js'; +import { readSourceState, recordingSourceState } from './lib/source-state.js'; + +const sha256 = content => createHash('sha256').update(content).digest('hex'); + +const help = `Generate short, verified vaRRI WebM demos. + +npm run demos -- [--only select-nodes] [--out output/playwright/demos] + --list List scene IDs without opening a browser + --only ID Record one scene instead of all scenes + --fps NUMBER Frames per second (default 15, range 10–30) + --crf NUMBER Compression strength (default VP9: 32, VP8: 10; range 4–50) + --max-kib NUMBER Fail if any video exceeds this size (default 4096 KiB) + --max-seconds N Duration budget (default 45 seconds) + --headed Show Chromium while recording + --help Show this help + +Optional environment: FFMPEG_PATH, VARRI_BROWSER_PATH. +Output: WebM, PNG poster, VTT, HTML preview, manifest, final-frame review PNG. +`; + +function number(value, fallback, min, max, name) { + const result = Number(value ?? fallback); + if (!Number.isInteger(result) || result < min || result > max) { + throw new Error(`${name} must be an integer between ${min} and ${max}`); + } + return result; +} + +async function main() { + const { values } = parseArgs({ options: { + list: { type: 'boolean' }, only: { type: 'string' }, out: { type: 'string' }, + fps: { type: 'string' }, crf: { type: 'string' }, 'max-kib': { type: 'string' }, + 'max-seconds': { type: 'string' }, + headed: { type: 'boolean' }, help: { type: 'boolean' }, + } }); + if (values.help) { console.log(help); return; } + if (values.list) { scenes.forEach(scene => console.log(`${scene.id}: ${scene.title}`)); return; } + const chosen = values.only ? scenes.filter(scene => scene.id === values.only) : scenes; + if (!chosen.length) throw new Error(`Unknown scene: ${values.only}. Use --list.`); + const sourceBefore = readSourceState(root); + const encoder = await findEncoder(); + const options = { fps: number(values.fps, 15, 10, 30, '--fps'), + crf: number(values.crf, encoder.codec === 'vp9' ? 32 : 10, 4, 50, '--crf'), + maxSeconds: number(values['max-seconds'], 45, 1, 120, '--max-seconds'), + maxBytes: number(values['max-kib'], 4096, 1, 10240, '--max-kib') * 1024 }; + const output = path.resolve(root, values.out || 'output/playwright/demos'); + console.log(`Encoder: ${encoder.codec.toUpperCase()} (${encoder.executable})`); + const scratch = path.join(root, 'output/playwright'); + await fs.mkdir(scratch, { recursive: true }); + const staging = await fs.mkdtemp(path.join(scratch, 'demo-build-')); + const server = await serve(root); + let browser; + const clips = []; + try { + browser = await chromium.launch({ headless: !values.headed, + ...(process.env.VARRI_BROWSER_PATH ? { executablePath: process.env.VARRI_BROWSER_PATH } : {}) }); + for (const scene of chosen) { + console.log(`Recording ${scene.id}…`); + const context = await browser.newContext({ viewport, deviceScaleFactor: 1, + locale: 'en-US', timezoneId: 'UTC', colorScheme: 'light' }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('response', response => { + if (response.url().startsWith(server.origin) && response.status() >= 400) errors.push(response.url()); + }); + await context.route('**/*', route => new URL(route.request().url()).origin === server.origin + ? route.continue() : route.abort()); + await page.addInitScript(() => { + let seed = 12345; + Math.random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296); + }); + let capture; + try { + await prepareStage(page, server.origin, scene); + if (scene.prepare) await scene.prepare(page); + const file = path.join(staging, `${scene.id}.webm`); + capture = new Capture(page, encoder, options, file); + await capture.poster(scene.title); + const posterFile = path.join(staging, `${scene.id}.png`); + await page.screenshot({ path: posterFile }); + // The first recorded instruction starts at zero; the poster has the title. + capture.cues = []; + await scene.run(capture, page); + const duration = await capture.finish(); + if (errors.length) throw new Error(`Browser errors: ${errors.join('; ')}`); + const content = await fs.readFile(file); + if (content.length > options.maxBytes) { + throw new Error(`${scene.id}: ${(content.length / 1024).toFixed(1)} KiB exceeds ${options.maxBytes / 1024} KiB. Increase --crf, shorten the scene, or explicitly raise --max-kib.`); + } + const captionsFile = path.join(staging, `${scene.id}.vtt`); + await writeCaptions(captionsFile, capture.cues, duration); + const poster = await fs.readFile(posterFile); + const captions = await fs.readFile(captionsFile); + clips.push({ id: scene.id, title: scene.title, description: scene.description, + kind: scene.stage && scene.stage !== 'canvas' ? 'walkthrough' : 'micro', + placements: scene.placements, stage: scene.stage || 'canvas', + file: `${scene.id}.webm`, poster: `${scene.id}.png`, captions: `${scene.id}.vtt`, + ...viewport, duration, bytes: content.length, codec: encoder.codec, + fps: options.fps, crf: options.crf, sha256: sha256(content), + posterBytes: poster.length, posterSha256: sha256(poster), + captionsBytes: captions.length, captionsSha256: sha256(captions), + cues: capture.cues, timeline: capture.timeline }); + console.log(` ${duration.toFixed(1)} s, ${(content.length / 1024).toFixed(1)} KiB`); + } catch (error) { + await page.screenshot({ path: path.join(staging, `${scene.id}-failure.png`) }).catch(() => {}); + if (capture) await capture.abort(); + throw error; + } finally { await context.close(); } + } + await writeGallery(staging, clips); + await verifyVideos(browser, staging, clips); + const sourceState = recordingSourceState(sourceBefore, readSourceState(root)); + await fs.writeFile(path.join(staging, 'manifest.json'), JSON.stringify({ + generatedAt: new Date().toISOString(), ...sourceState, + sizeBudgetBytes: options.maxBytes, durationBudgetSeconds: options.maxSeconds, + aggregateWebmBytes: clips.reduce((sum, clip) => sum + clip.bytes, 0), clips, + }, null, 2) + '\n'); + await fs.mkdir(output, { recursive: true }); + for (const file of await fs.readdir(staging)) await fs.copyFile(path.join(staging, file), path.join(output, file)); + await fs.rm(staging, { recursive: true }); + console.log(`Ready: ${path.join(output, 'index.html')}`); + } catch (error) { + console.error(`Diagnostic files: ${staging}`); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } +} + +main().catch(error => { console.error(error.stack || error.message); process.exitCode = 1; }); diff --git a/recording/scenes/add-point-mutation.js b/recording/scenes/add-point-mutation.js new file mode 100644 index 0000000..25612e9 --- /dev/null +++ b/recording/scenes/add-point-mutation.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { openPanel, ready } from './helpers.js'; + +export default { + id: 'add-point-mutation', title: 'Annotate a point mutation', + stage: 'dialog', zoom: false, + placements: ['docs:point-mutations', 'input-panel:mutations'], + parameters: { + sequence: 'AGCU&AGCU', structure: '((..&..))', startIndex2: '101', + forceLayout: '0', hideFooterAndHeader: '1', + }, + description: 'Annotate G102A on the second strand of the supplied structure without editing the input sequence or predicting a fold.', + async prepare(page) { + await openPanel(page, '#mutationSubmitBtn'); + await page.addStyleTag({ content: ` + #mutationDialog .mutationInput { + grid-template-columns: 60px minmax(0, 1fr) 60px 44px; + } + ` }); + }, + async run(capture, page) { + const target = page.locator('#rendering-canvas circle[node_num="6"]'); + const replacement = page.locator('#rendering-canvas [label_gnum="6"] text[label_type="label"]'); + assert.equal(await page.locator('#mutation-list .mutation-item').count(), 0); + assert.equal(await page.locator('#startIndex2').inputValue(), '101'); + assert.equal(await target.locator('title').textContent(), 's2[102]'); + + await capture.caption('Click Add in Point Mutations to annotate the supplied structure'); + await capture.click(page.locator('#mutationSubmitBtn')); + await page.locator('#mutationDialog').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#mutationColor').inputValue(), '#006400'); + + await capture.caption('Choose Seq. 2 for the second RNA strand'); + await capture.select(page.locator('#mutationSequence'), '2'); + assert.equal(await page.locator('#mutationSequence').inputValue(), '2'); + + await capture.caption('Enter biological position 102'); + await capture.fill(page.locator('#mutationPosition'), '102'); + assert.equal(await page.locator('#mutationPosition').inputValue(), '102'); + + await capture.caption('Enter A in To for the replacement annotation'); + await capture.fill(page.locator('#mutationBase'), 'A'); + assert.equal(await page.locator('#mutationBase').inputValue(), 'A'); + assert.equal(await page.locator('#mutationColor').inputValue(), '#006400'); + + await capture.caption('Click OK to add the G102A annotation'); + await capture.click(page.locator('#mutationDialog button[value="ok"]')); + await ready(page); + await replacement.waitFor({ state: 'visible' }); + assert.equal(await page.locator('#mutationDialog').isVisible(), false); + assert.equal(await page.locator('#mutation-list .mutation-item').count(), 1); + assert.match(await page.locator('#mutation-list').textContent(), /Seq 2\s*:\s*G102A/); + assert.equal(await target.locator('title').textContent(), 's2[102]'); + assert.equal(await page.locator('#rendering-canvas g[num="n6"] text').textContent(), 'G', + 'The original nucleotide remains G; the replacement is an additional annotation'); + assert.equal(await replacement.textContent(), 'A', 'The actual replacement label is visible'); + assert.equal(await page.locator('#rendering-canvas [label_gnum="6"] title').textContent(), + 'Mutation: G102A'); + const annotation = await target.evaluate(element => ({ + stroke: getComputedStyle(element).stroke, + width: getComputedStyle(element).strokeWidth, + outlinedNodes: [...document.querySelectorAll('#rendering-canvas circle[node_type="nucleotide"]')] + .filter(node => getComputedStyle(node).stroke === 'rgb(0, 100, 0)') + .map(node => node.getAttribute('node_num')), + })); + assert.deepEqual(annotation, { stroke: 'rgb(0, 100, 0)', width: '2px', outlinedNodes: ['6'] }); + assert.equal(await replacement.evaluate(element => getComputedStyle(element).fill), 'rgb(0, 100, 0)'); + assert.equal(await page.locator('#sequence').inputValue(), 'AGCU&AGCU'); + assert.equal(await page.locator('#structure').inputValue(), '((..&..))'); + assert.equal(await page.locator('#rendering-canvas circle[node_type="nucleotide"]').count(), 8); + assert.equal(await page.locator('#msg.error:visible').count(), 0); + + await capture.move({ x: 365, y: 160 }); + await capture.hold(1); + await capture.caption('G102A adds a green outline and A label; inputs stay unchanged and no fold is predicted'); + await capture.clearCaption(); + await capture.hold(3); + }, +}; diff --git a/recording/scenes/add-region-highlight.js b/recording/scenes/add-region-highlight.js new file mode 100644 index 0000000..44e2692 --- /dev/null +++ b/recording/scenes/add-region-highlight.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { openPanel, ready } from './helpers.js'; + +export default { + id: 'add-region-highlight', title: 'Add a paired region highlight', stage: 'dialog', zoom: false, + placements: ['docs:region-highlights'], + parameters: { hideFooterAndHeader: '1', sequence: 'ACGU&UGCA', structure: '((..&..))', backgroundhighlighting: 'nothing' }, + description: 'Enter one range for each RNA strand and add a translucent paired-region highlight.', + async prepare(page) { + await openPanel(page, '#regionSubmitBtn'); + await page.addStyleTag({ content: ` + #regionHighlightDialog .regionInput { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 24px 82px; + } + #regionAlpha { width: 100%; min-width: 82px; } + ` }); + }, + async run(capture, page) { + const region = page.locator('#rendering-canvas polygon[data-varri-region="true"]'); + assert.equal(await region.count(), 0, 'The initial drawing has no region highlight'); + await capture.caption('Click Add in Region Highlights'); + await capture.click(page.locator('#regionSubmitBtn')); + await page.locator('#regionHighlightDialog').waitFor({ state: 'visible' }); + await capture.caption('Enter 1-2 for the first RNA strand'); + await capture.fill(page.locator('#region1'), '1-2'); + assert.equal(await page.locator('#region1').inputValue(), '1-2'); + await capture.caption('Enter 3-4 for its partner on the second RNA'); + await capture.fill(page.locator('#region2'), '3-4'); + assert.equal(await page.locator('#region2').inputValue(), '3-4'); + await capture.caption('Set opacity to 0.5 for a translucent highlight'); + await capture.select(page.locator('#regionAlpha'), '0.5'); + assert.equal(await page.locator('#regionAlpha').inputValue(), '0.5'); + await capture.caption('Click OK to add the highlight to the drawing'); + await capture.click(page.locator('#regionHighlightDialog button[value="ok"]')); + await ready(page); + assert.equal(await page.locator('#regionHighlightDialog').isVisible(), false); + assert.equal(await page.locator('#region-list .highlight-item:not(.generated)').count(), 1); + assert.match(await page.locator('#region-list').textContent(), /1-2&3-4/); + assert.equal(await region.count(), 1, 'Exactly one real region polygon is drawn'); + const geometry = await region.evaluate(element => ({ + opacity: getComputedStyle(element).opacity, + points: [...element.points].map(point => [point.x, point.y]), + expected: [1, 2, 7, 8, 1].map(id => { + const node = document.querySelector(`#rendering-canvas g[num="n${id}"]`); + const transform = node.transform.baseVal.consolidate().matrix; + return [transform.e, transform.f]; + }), + })); + assert.equal(geometry.opacity, '0.5'); + assert.equal(geometry.points.length, 5, 'The paired range forms a closed four-node polygon'); + geometry.points.forEach((point, index) => point.forEach((value, axis) => + assert.ok(Math.abs(value - geometry.expected[index][axis]) < 0.001, + 'The polygon must follow RNA 1 positions 1-2 and RNA 2 positions 3-4'))); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1); + await capture.caption('The paired region is highlighted and listed on the left'); + await capture.clearCaption(); + await capture.hold(2); + }, +}; diff --git a/recording/scenes/add-subsequence-highlight.js b/recording/scenes/add-subsequence-highlight.js new file mode 100644 index 0000000..87b686e --- /dev/null +++ b/recording/scenes/add-subsequence-highlight.js @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { openPanel, ready } from './helpers.js'; + +export default { + id: 'add-subsequence-highlight', title: 'Add a subsequence highlight', + stage: 'dialog', zoom: false, + placements: ['docs:subsequence-highlights', 'input-panel:subsequences'], + parameters: { + sequence: 'AGCU&AGCU', structure: '((..&..))', startIndex2: '101', + forceLayout: '0', hideFooterAndHeader: '1', + }, + description: 'Choose the second strand and highlight biological positions 102–103 at half opacity.', + async prepare(page) { + await openPanel(page, '#highlightSubmitBtn'); + await page.addStyleTag({ content: ` + #subseqHighlightDialog .highlightInput { + grid-template-columns: 60px minmax(0, 1fr) 44px 82px; + } + #subseqSequence, #subseqAlpha { width: 100%; } + #subseqAlpha { min-width: 82px; } + ` }); + }, + async run(capture, page) { + const overlay = page.locator('#rendering-canvas [data-varri-subseq="true"]'); + assert.equal(await overlay.count(), 0, 'The initial drawing has no subsequence overlay'); + assert.equal(await page.locator('#startIndex2').inputValue(), '101'); + + await capture.caption('Click Add in Subsequence Highlights'); + await capture.click(page.locator('#highlightSubmitBtn')); + await page.locator('#subseqHighlightDialog').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#subseqColor').inputValue(), '#800080'); + + await capture.caption('Choose Seq. 2 for the second RNA strand'); + await capture.select(page.locator('#subseqSequence'), '2'); + assert.equal(await page.locator('#subseqSequence').inputValue(), '2'); + + await capture.caption('Enter biological range 102-103'); + await capture.fill(page.locator('#subseqRange'), '102-103'); + assert.equal(await page.locator('#subseqRange').inputValue(), '102-103'); + + await capture.caption('Set opacity to 0.5'); + await capture.select(page.locator('#subseqAlpha'), '0.5'); + assert.equal(await page.locator('#subseqAlpha').inputValue(), '0.5'); + assert.equal(await page.locator('#subseqColor').inputValue(), '#800080'); + + await capture.caption('Click OK to add the highlight'); + await capture.click(page.locator('#subseqHighlightDialog button[value="ok"]')); + await ready(page); + await overlay.waitFor({ state: 'visible' }); + assert.equal(await page.locator('#subseqHighlightDialog').isVisible(), false); + assert.equal(await page.locator('#highlight-list .highlight-item').count(), 1); + assert.match(await page.locator('#highlight-list').textContent(), /2.*102-103/); + assert.equal(await overlay.count(), 1, 'Exactly one real subsequence overlay is drawn'); + const geometry = await overlay.evaluate(element => ({ + tag: element.tagName, + opacity: getComputedStyle(element).opacity, + stroke: getComputedStyle(element).stroke, + points: [...element.points].map(point => [point.x, point.y]), + expected: [6, 7].map(id => { + const node = document.querySelector(`#rendering-canvas g[num="n${id}"]`); + const transform = node.transform.baseVal.consolidate().matrix; + return [transform.e, transform.f]; + }), + })); + assert.equal(geometry.tag, 'polyline'); + assert.equal(geometry.opacity, '0.5'); + assert.equal(geometry.stroke, 'rgb(128, 0, 128)'); + assert.equal(geometry.points.length, 2, 'The range spans exactly nodes 6 and 7'); + geometry.points.forEach((point, index) => point.forEach((value, axis) => + assert.ok(Math.abs(value - geometry.expected[index][axis]) < 0.001, + 'The overlay must follow biological positions 102 and 103 on strand 2'))); + assert.deepEqual(await page.locator('#rendering-canvas circle[node_type="nucleotide"] title') + .evaluateAll(titles => titles.slice(5, 7).map(title => title.textContent)), + ['s2[102]', 's2[103]']); + assert.equal(await page.locator('#sequence').inputValue(), 'AGCU&AGCU'); + assert.equal(await page.locator('#structure').inputValue(), '((..&..))'); + assert.equal(await page.locator('#msg.error:visible').count(), 0); + + await capture.move({ x: 365, y: 160 }); + await capture.hold(1); + await capture.caption('The purple range is highlighted and listed'); + await capture.clearCaption(); + await capture.hold(3); + }, +}; diff --git a/recording/scenes/apply-probability-profile.js b/recording/scenes/apply-probability-profile.js new file mode 100644 index 0000000..93e6063 --- /dev/null +++ b/recording/scenes/apply-probability-profile.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { openPanel, ready } from './helpers.js'; + +export default { + id: 'apply-probability-profile', title: 'Apply a probability profile', stage: 'full-ui', zoom: false, + placements: ['docs:probability-profiles'], + parameters: { hideFooterAndHeader: '1', sequence: 'ACGU&UGCA', structure: '((..&..))', startIndex1: '-2' }, + description: 'Enter profile values, choose biological start-index coordinates, and apply the nucleotide color gradient.', + async prepare(page) { + await openPanel(page, '#profileData1'); + await page.addStyleTag({ content: ` + aside[aria-label="Probability profiles"] .checkbox-row { flex-direction: column; align-items: stretch; } + #profileIdxRef1, #profileIdxRef2 { width: 100%; } + ` }); + }, + async run(capture, page) { + const profile = '-2 0.15\n-1 0.45\n1 0.8\n2 1.0'; + await capture.caption('Enter each nucleotide index and its probability for RNA 1.'); + await capture.paste(page.locator('#profileData1'), profile); + assert.equal(await page.locator('#profileData1').inputValue(), profile); + await capture.caption('Choose Start 1: indices are -2, -1, 1, 2, skipping zero.'); + await capture.select(page.locator('#profileIdxRef1'), 'start'); + assert.equal(await page.locator('#profileIdxRef1').inputValue(), 'start'); + await ready(page); + assert.equal(await page.locator('#profileColorRepresentsOne1').isChecked(), true); + await capture.caption('Uncheck =1 so purple represents zero; higher values fade the overlay.'); + await capture.click(page.locator('#profileColorRepresentsOne1')); + assert.equal(await page.locator('#profileColorRepresentsOne1').isChecked(), false); + await ready(page); + await capture.caption('Click Apply to color RNA 1 using the probability profile.'); + await capture.click(page.locator('#profileApplyBtn')); + await ready(page); + assert.equal((await page.locator('#profileCounterUI').textContent()).trim(), '(1)'); + const overlays = await page.locator('#rendering-canvas circle[node_num^="o"]').evaluateAll(nodes => + nodes.map(node => ({ id: node.getAttribute('node_num'), opacity: Number(getComputedStyle(node).opacity), + fill: getComputedStyle(node).fill }))); + assert.deepEqual(overlays.map(node => node.id), ['o1', 'o2', 'o3', 'o4'], + 'Start-based indices must map onto all four RNA 1 nucleotides'); + assert.deepEqual(overlays.map(node => node.opacity), [0.85, 0.55, 0.2, 0], + 'Unchecking =1 must make higher probabilities progressively lighter'); + assert.ok(overlays.every(node => node.fill === 'rgb(128, 0, 128)'), + 'The profile must use the displayed purple color'); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.2); + await capture.caption('Lower probabilities have stronger purple shading on RNA 1.'); + await capture.clearCaption(); + await capture.hold(1.6); + }, +}; diff --git a/recording/scenes/choose-example.js b/recording/scenes/choose-example.js new file mode 100644 index 0000000..1e112df --- /dev/null +++ b/recording/scenes/choose-example.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import examples from '../../src/ui/examples-data.js'; +import { ready } from './helpers.js'; + +const example = examples['wu-2024']; + +export default { + id: 'choose-example', title: 'Choose an example', stage: 'full-ui', zoom: false, + placements: ['docs:example-input', 'input-panel:examples'], + parameters: { hideFooterAndHeader: '1' }, + description: 'Choose Mutations & highlighting to load its RNA input, four mutations, and a subsequence highlight.', + async run(capture, page) { + await capture.caption('Open Example to browse the built-in examples'); + await capture.click(page.locator('#exampleDropdownTrigger')); + assert.equal(await page.locator('#exampleDropdown').evaluate(element => element.open), true); + await capture.caption('Choose Mutations & highlighting'); + await capture.click(page.locator('[data-example="wu-2024"]')); + await ready(page); + await page.waitForFunction(() => + document.querySelectorAll('#rendering-canvas circle[node_type="nucleotide"]').length === 61); + assert.equal(await page.locator('#exampleDropdown').evaluate(element => element.open), false); + assert.equal(await page.locator('#selectedExampleName').textContent(), example.nameShort); + for (const field of ['sequence', 'structure', 'startIndex1', 'startIndex2']) { + assert.equal(await page.locator(`#${field}`).inputValue(), example.vaRRIParams.fields[field]); + } + assert.equal(await page.locator('#mutation-list [data-mutation-id]').count(), 4); + assert.equal(await page.locator('#highlight-list [data-highlight-id]').count(), 1); + assert.equal(await page.locator('#rendering-canvas [data-varri-subseq]').count(), 1); + assert.deepEqual(await page.locator('#rendering-canvas [label_type="label"]') + .evaluateAll(labels => labels.filter(label => label.parentElement.querySelector('title') + ?.textContent.startsWith('Mutation:')).map(label => label.textContent).sort()), ['C', 'C', 'G', 'G']); + assert.equal(await page.locator('#msg.error:visible').count(), 0); + await capture.move({ x: 365, y: 160 }); + await capture.hold(1.5); + await capture.caption('The inputs and figure include four mutations and a green highlight'); + await capture.clearCaption(); + await capture.hold(3); + }, +}; diff --git a/recording/scenes/export-png.js b/recording/scenes/export-png.js new file mode 100644 index 0000000..c3a17e0 --- /dev/null +++ b/recording/scenes/export-png.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +async function checkPNG(download, width, height, dpi) { + assert.equal(download.suggestedFilename(), 'vaRRI_output.png'); + assert.equal(await download.failure(), null, 'PNG download completes successfully'); + const bytes = await fs.readFile(await download.path()); + assert.equal(bytes.subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); + assert.equal(bytes.toString('ascii', 12, 16), 'IHDR'); + assert.equal(bytes.readUInt32BE(16), width); + assert.equal(bytes.readUInt32BE(20), height); + let density; + for (let offset = 8; offset + 12 <= bytes.length;) { + const length = bytes.readUInt32BE(offset); + if (bytes.toString('ascii', offset + 4, offset + 8) === 'pHYs') { + density = bytes.subarray(offset + 8, offset + 8 + length); + } + offset += length + 12; + } + assert.ok(density, 'PNG includes print-density metadata'); + assert.equal(density.readUInt32BE(0), Math.round(dpi / 0.0254)); + assert.equal(density.readUInt32BE(4), Math.round(dpi / 0.0254)); + assert.equal(density[8], 1, 'PNG density is measured in pixels per metre'); +} + +export default { + id: 'export-png', title: 'Export a publication PNG', stage: 'export-dialog', zoom: false, + placements: ['docs:export'], + parameters: { hideFooterAndHeader: '1' }, + description: 'Set pixel dimensions and print density in the PNG export dialog, then download the image.', + async run(capture, page) { + await capture.caption('Click PNG to choose image size and print density'); + await capture.click(page.locator('#exportPngBtn')); + await page.locator('#pngExportDialog').waitFor({ state: 'visible' }); + const originalWidth = Number(await page.locator('#pngWidth').inputValue()); + const before = Number(await page.locator('#pngHeight').inputValue()); + await capture.caption('Set width to 1200 pixels; height follows the aspect ratio'); + await capture.fill(page.locator('#pngWidth'), '1200'); + const height = Number(await page.locator('#pngHeight').inputValue()); + assert.notEqual(height, before); + assert.ok(Math.abs(height - 1200 * before / originalWidth) <= 1); + await capture.hold(0.8); + await capture.caption('Set DPI to 300; the pixel dimensions stay the same'); + await capture.fill(page.locator('#pngDpi'), '300'); + assert.equal(await page.locator('#pngWidth').inputValue(), '1200'); + assert.equal(Number(await page.locator('#pngHeight').inputValue()), height); + assert.equal(await page.locator('#pngDpi').inputValue(), '300'); + await capture.hold(0.8); + const downloadPromise = page.waitForEvent('download'); + await capture.caption('Click Export PNG to download the image'); + await capture.click(page.locator('#pngExportDialog button[value="ok"]')); + const download = await downloadPromise; + await checkPNG(download, 1200, height, 300); + await page.locator('#pngExportDialog').waitFor({ state: 'hidden' }); + await capture.move({ x: 80, y: 140 }); + await capture.caption(`PNG downloaded: 1200 × ${height} pixels at 300 DPI`); + await capture.clearCaption(); + await capture.hold(1.5); + }, +}; diff --git a/recording/scenes/helpers.js b/recording/scenes/helpers.js new file mode 100644 index 0000000..0f57378 --- /dev/null +++ b/recording/scenes/helpers.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; + +// Indices here are zero-based DOM order, not biological nucleotide positions. +export const node = (page, index) => page.locator('#rendering-canvas circle[node_type="nucleotide"]').nth(index); +export const selected = page => page.locator('#rendering-canvas [data-varri-selected]').count(); +export const near = (a, b) => assert.ok(Math.abs(a - b) < 1, `Expected ${a} to be close to ${b}`); + +export async function selectPair(capture, page) { + await capture.caption('Ctrl / ⌘ + click to select nodes'); + await capture.hold(1); + await capture.click(node(page, 3), 'Control'); + await capture.click(node(page, 4), 'Control'); + assert.equal(await selected(page), 2, 'Both nodes must actually be selected'); +} + +export async function openPanel(page, selector) { + const details = page.locator(selector).locator('xpath=ancestor::details[1]'); + if (await details.count() && !(await details.evaluate(element => element.open))) { + await details.locator(':scope > summary').click(); + } + await details.evaluate(element => element.scrollIntoView({ block: 'nearest' })); +} + +export const ready = page => page.waitForFunction(() => + document.getElementById('rendering-canvas')?.style.visibility !== 'hidden' && + document.querySelector('#rendering-canvas circle[node_type="nucleotide"]')); diff --git a/recording/scenes/highlight-rri-background.js b/recording/scenes/highlight-rri-background.js new file mode 100644 index 0000000..e10605f --- /dev/null +++ b/recording/scenes/highlight-rri-background.js @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { openPanel } from './helpers.js'; + +const region = '#rendering-canvas [data-varri-region="true"]'; +const entries = '#region-list .highlight-item'; +const expected = { + nothing: { ranges: [], paths: [] }, + region: { ranges: ['1-5&1-5'], paths: [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1]] }, + basepairs: { ranges: ['1-2&4-5', '4-5&1-2'], paths: [[1, 2, 9, 10, 1], [4, 5, 6, 7, 4]] }, +}; + +async function waitForBackground(page, mode) { + await page.waitForFunction(({ mode, ranges, region, entries }) => { + const canvas = document.querySelector('#rendering-canvas'); + const labels = [...document.querySelectorAll(`${entries} .highlight-item-main`)] + .map(button => button.textContent.trim()); + return document.querySelector('#backgroundhighlighting')?.value === mode && + canvas?.style.visibility !== 'hidden' && + canvas?.querySelectorAll('circle[node_type="nucleotide"]').length === 10 && + document.querySelectorAll(region).length === ranges.length && + JSON.stringify(labels) === JSON.stringify(ranges); + }, { mode, ranges: expected[mode].ranges, region, entries }); +} + +async function assertBackground(page, mode) { + await waitForBackground(page, mode); + const { ranges, paths } = expected[mode]; + assert.deepEqual(await page.locator(`${entries} .highlight-item-main`).allTextContents(), ranges); + assert.equal(await page.locator(`${entries}.generated`).count(), ranges.length); + assert.equal(await page.locator(`${entries}:not(.generated)`).count(), 0); + assert.equal(await page.locator(`${entries} .highlight-delete`).count(), 0); + for (const button of await page.locator(`${entries} .highlight-item-main`).all()) { + assert.equal(await button.isDisabled(), true, 'Generated regions cannot be edited'); + assert.equal(await button.getAttribute('title'), 'Generated region highlight'); + } + assert.equal((await page.locator('#regionCounterUI').textContent()).trim(), + ranges.length ? `(${ranges.length})` : ''); + assert.equal(await page.locator('#region-list .highlight-empty').count(), ranges.length ? 0 : 1); + const geometry = await page.locator(region).evaluateAll((elements, paths) => ({ + polygons: elements.map(element => ({ + tag: element.tagName, + fill: getComputedStyle(element).fill, + opacity: getComputedStyle(element).opacity, + points: [...element.points].map(point => [point.x, point.y]), + })), + expected: paths.map(path => path.map(id => { + const node = document.querySelector(`#rendering-canvas g[num="n${id}"]`); + const transform = node.transform.baseVal.consolidate().matrix; + return [transform.e, transform.f]; + })), + }), paths); + assert.equal(geometry.polygons.length, paths.length); + for (const polygon of geometry.polygons) { + assert.equal(polygon.tag, 'polygon'); + assert.equal(polygon.fill, 'rgb(255, 0, 0)'); + assert.equal(polygon.opacity, '0.2'); + const match = geometry.expected.findIndex(path => path.length === polygon.points.length && + path.every((point, index) => point.every((value, axis) => + Math.abs(value - polygon.points[index][axis]) < 0.001))); + assert.notEqual(match, -1, 'Every polygon must follow its exact real nucleotide range'); + geometry.expected.splice(match, 1); + } + assert.equal(geometry.expected.length, 0, 'Every expected range has its own polygon'); + assert.equal(await page.locator('#sequence').inputValue(), 'GGAGG&CCACC'); + assert.equal(await page.locator('#structure').inputValue(), '((.((&)).))'); + assert.equal(await page.locator('#highlighting').inputValue(), 'nothing'); + assert.equal(await page.locator('#forceLayout').isChecked(), false); + assert.equal(await page.locator('#msg.error:visible').count(), 0); +} + +export default { + id: 'highlight-rri-background', title: 'Highlight the RRI background', + stage: 'full-ui', zoom: false, + placements: ['docs:region-highlights', 'input-panel:rri-background'], + parameters: { + sequence: 'GGAGG&CCACC', structure: '((.((&)).))', + forceLayout: '0', highlighting: 'nothing', backgroundhighlighting: 'nothing', + hideFooterAndHeader: '1', + }, + description: 'Use the RRI background dropdown to generate one whole-region highlight, separate paired-stack highlights, or remove the automatic highlights.', + async prepare(page) { + await openPanel(page, '#backgroundhighlighting'); + await page.addStyleTag({ content: ` + .label-dropdown-col-row { + grid-template-columns: 132px minmax(0, 1fr) 22px; + gap: 6px; + } + ` }); + }, + async run(capture, page) { + const select = page.locator('#backgroundhighlighting'); + await assertBackground(page, 'nothing'); + assert.equal(await page.locator('#colorRriRegion').inputValue(), '#ff0000'); + + await capture.caption('Choose region: this dropdown controls automatic RRI backgrounds'); + await capture.click(select); + await capture.press('Home'); + await capture.press('ArrowDown'); + await capture.press('Enter', undefined, () => waitForBackground(page, 'region')); + await assertBackground(page, 'region'); + await capture.move({ x: 365, y: 160 }); + await capture.hold(0.75); + await capture.caption('Region adds one generated highlight: 1-5&1-5'); + await capture.clearCaption(); + await capture.hold(1.5); + + await capture.caption('Choose basepairs to separate the two paired stacks'); + await capture.click(select); + await capture.press('Home'); + await capture.press('Enter', undefined, () => waitForBackground(page, 'basepairs')); + await assertBackground(page, 'basepairs'); + await capture.move({ x: 365, y: 160 }); + await capture.hold(0.75); + await capture.caption('Two generated highlights leave the unpaired A bases clear'); + await capture.clearCaption(); + await capture.hold(1.5); + + await capture.caption('Choose nothing to remove the automatic highlights'); + await capture.click(select); + await capture.press('Home'); + await capture.press('ArrowDown'); + await capture.press('ArrowDown'); + await capture.press('Enter', undefined, () => waitForBackground(page, 'nothing')); + await assertBackground(page, 'nothing'); + await capture.move({ x: 365, y: 160 }); + await capture.hold(0.75); + await capture.caption('The generated entries and backgrounds are gone; the input is unchanged'); + await capture.clearCaption(); + await capture.hold(2.5); + }, +}; diff --git a/recording/scenes/highlight-rri-nucleotides.js b/recording/scenes/highlight-rri-nucleotides.js new file mode 100644 index 0000000..f238929 --- /dev/null +++ b/recording/scenes/highlight-rri-nucleotides.js @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { openPanel } from './helpers.js'; + +const allNodes = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const pairedNodes = [1, 2, 4, 5, 6, 7, 9, 10]; +const nucleotide = '#rendering-canvas circle[node_type="nucleotide"]'; + +async function waitForOutlines(page, mode, expected) { + await page.waitForFunction(({ mode, expected, nucleotide }) => { + const canvas = document.querySelector('#rendering-canvas'); + const nodes = [...document.querySelectorAll(nucleotide)]; + const outlined = nodes.filter(node => getComputedStyle(node).stroke === 'rgb(255, 0, 0)') + .map(node => Number(node.getAttribute('node_num'))); + return document.querySelector('#highlighting')?.value === mode && + canvas?.style.visibility !== 'hidden' && nodes.length === 10 && + JSON.stringify(outlined) === JSON.stringify(expected); + }, { mode, expected, nucleotide }); +} + +async function assertOutlines(page, mode, expected) { + await waitForOutlines(page, mode, expected); + const styles = await page.locator(nucleotide).evaluateAll(nodes => nodes.map(node => ({ + id: Number(node.getAttribute('node_num')), + stroke: getComputedStyle(node).stroke, + width: getComputedStyle(node).strokeWidth, + }))); + assert.deepEqual(styles.filter(node => node.stroke === 'rgb(255, 0, 0)').map(node => node.id), + expected, 'The actual SVG outlines must match the selected RRI mode'); + styles.forEach(node => { + assert.equal(node.width, '0.8px'); + assert.equal(node.stroke, expected.includes(node.id) ? 'rgb(255, 0, 0)' : 'rgb(204, 204, 204)'); + }); + assert.equal(await page.locator('#backgroundhighlighting').inputValue(), 'nothing'); + assert.equal(await page.locator('#rendering-canvas [data-varri-region="true"]').count(), 0); + assert.equal(await page.locator('#forceLayout').isChecked(), false); + assert.equal(await page.locator('#msg.error:visible').count(), 0); +} + +export default { + id: 'highlight-rri-nucleotides', title: 'Highlight RRI nucleotides', + stage: 'full-ui', zoom: false, + placements: ['docs:visualisation-settings', 'input-panel:rri-nucleotides'], + parameters: { + sequence: 'GGAGG&CCACC', structure: '((.((&)).))', + forceLayout: '0', highlighting: 'nothing', backgroundhighlighting: 'nothing', + hideFooterAndHeader: '1', + }, + description: 'Compare outlines around all ten interaction-region nucleotides with the eight paired nucleotides, excluding the two unpaired A bases.', + async prepare(page) { + await openPanel(page, '#highlighting'); + await page.addStyleTag({ content: ` + .label-dropdown-col-row { + grid-template-columns: 132px minmax(0, 1fr) 22px; + gap: 6px; + } + ` }); + }, + async run(capture, page) { + const select = page.locator('#highlighting'); + await assertOutlines(page, 'nothing', []); + assert.equal(await page.locator('#colorRriNodes').inputValue(), '#ff0000'); + + await capture.caption('Choose region for RRI nucleotide highlighting'); + await capture.click(select); + await capture.press('Home'); + await capture.press('Enter', undefined, () => waitForOutlines(page, 'region', allNodes)); + await assertOutlines(page, 'region', allNodes); + await capture.move({ x: 365, y: 160 }); + await capture.hold(1); + await capture.caption('Region outlines all 10 nucleotides, including both unpaired A bases'); + await capture.clearCaption(); + await capture.hold(3); + + await capture.caption('Choose basepairs to outline only paired nucleotides'); + await capture.click(select); + await capture.press('Home'); + await capture.press('ArrowDown'); + await capture.press('Enter', undefined, () => waitForOutlines(page, 'basepairs', pairedNodes)); + await assertOutlines(page, 'basepairs', pairedNodes); + assert.deepEqual(await page.locator(nucleotide).evaluateAll(nodes => + nodes.filter(node => [3, 8].includes(Number(node.getAttribute('node_num')))) + .map(node => node.parentElement.querySelector('text').textContent)), ['A', 'A']); + assert.equal(await page.locator('#sequence').inputValue(), 'GGAGG&CCACC'); + assert.equal(await page.locator('#structure').inputValue(), '((.((&)).))'); + await capture.move({ x: 365, y: 160 }); + await capture.hold(1); + await capture.caption('Basepairs outlines 8 nucleotides; both unpaired A bases lose the red outline'); + await capture.clearCaption(); + await capture.hold(4); + }, +}; diff --git a/recording/scenes/import-fasta.js b/recording/scenes/import-fasta.js new file mode 100644 index 0000000..5cd98f4 --- /dev/null +++ b/recording/scenes/import-fasta.js @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { ready } from './helpers.js'; + +const fasta = '>First RNA\nACGU\n((..\n>Second RNA\nUGGU\n..))'; + +export default { + id: 'import-fasta', title: 'Import FASTA input', stage: 'dialog', zoom: false, + placements: ['docs:fasta-input'], + parameters: { hideFooterAndHeader: '1', sequence: 'AAAAAA&UUUUUU', structure: '......&......' }, + description: 'Paste two FASTA records, inspect the parsed sequences, structures, and names, then render them.', + async run(capture, page) { + await capture.caption('Open FASTA Data to import RNA records'); + await capture.click(page.locator('#fastaInputBtn')); + assert.equal(await page.locator('#fastaDialog').evaluate(dialog => dialog.open), true); + await capture.caption('Paste two FASTA records with sequences and structures'); + await capture.paste(page.locator('#fastaInput'), fasta); + await page.waitForFunction(() => document.getElementById('fastaSequence')?.value === 'ACGU&UGGU'); + assert.equal(await page.locator('#fastaInput').inputValue(), fasta); + assert.equal(await page.locator('#fastaStructure').inputValue(), '((..&..))'); + assert.equal(await page.locator('#fastaSeqName1').inputValue(), 'First'); + assert.equal(await page.locator('#fastaSeqName2').inputValue(), 'Second'); + await capture.caption('Check the parsed sequences, structures, and names'); + await capture.clearCaption(); + await capture.move({ x: 130, y: 300 }); + await capture.hold(2.2); + await capture.caption('Click OK to render the imported RNA'); + await capture.click(page.locator('#fastaDialog button[value="ok"]')); + await ready(page); + await page.waitForFunction(() => + document.querySelectorAll('#rendering-canvas circle[node_type="nucleotide"]').length === 8); + assert.equal(await page.locator('#fastaDialog').evaluate(dialog => dialog.open), false); + assert.equal(await page.locator('#sequence').inputValue(), 'ACGU&UGGU'); + assert.equal(await page.locator('#structure').inputValue(), '((..&..))'); + assert.equal(await page.locator('#seqName1').inputValue(), 'First'); + assert.equal(await page.locator('#seqName2').inputValue(), 'Second'); + assert.deepEqual(await page.locator('#rendering-canvas [data-varri-text] text').allTextContents(), + ['First', 'Second']); + assert.equal(await page.locator('#rendering-canvas line').evaluateAll(lines => + lines.filter(line => ['basepair', 'pseudoknot'].includes(line.__data__?.linkType)).length), 2); + await capture.move({ x: 80, y: 140 }); + await capture.hold(2.5); + }, +}; diff --git a/recording/scenes/index.js b/recording/scenes/index.js new file mode 100644 index 0000000..132bd0d --- /dev/null +++ b/recording/scenes/index.js @@ -0,0 +1,43 @@ +import selectNodes from './select-nodes.js'; +import moveSelection from './move-selection.js'; +import resetPositions from './reset-positions.js'; +import releasePositions from './release-positions.js'; +import rectangleSelection from './rectangle-selection.js'; +import rotateSelection from './rotate-selection.js'; +import placeTextAnnotation from './place-text-annotation.js'; +import importFasta from './import-fasta.js'; +import applyProbabilityProfile from './apply-probability-profile.js'; +import storeLoadSession from './store-load-session.js'; +import exportPng from './export-png.js'; +import addRegionHighlight from './add-region-highlight.js'; +import chooseExample from './choose-example.js'; +import nameAndNumberStrand from './name-and-number-strand.js'; +import addSubsequenceHighlight from './add-subsequence-highlight.js'; +import addPointMutation from './add-point-mutation.js'; +import highlightRriNucleotides from './highlight-rri-nucleotides.js'; +import highlightRriBackground from './highlight-rri-background.js'; +import linearizeRriLayout from './linearize-rri-layout.js'; + +// Add a scene module here to include it in --list and the default recording run. +export const scenes = [selectNodes, moveSelection, releasePositions, resetPositions, + rectangleSelection, rotateSelection, placeTextAnnotation, importFasta, + applyProbabilityProfile, storeLoadSession, exportPng, addRegionHighlight, chooseExample, + nameAndNumberStrand, addSubsequenceHighlight, addPointMutation, highlightRriNucleotides, + highlightRriBackground, linearizeRriLayout]; + +const ids = new Set(); +for (const scene of scenes) { + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(scene.id) || ids.has(scene.id)) { + throw new Error(`Scene IDs must be unique filename-safe names: ${scene.id}`); + } + if (!scene.title || !scene.description || typeof scene.run !== 'function') { + throw new Error(`Scene ${scene.id} requires a title, description, and run(capture, page).`); + } + if (!['canvas', 'full-ui', 'dialog', 'export-dialog'].includes(scene.stage || 'canvas')) { + throw new Error(`Scene ${scene.id} has an unsupported stage profile.`); + } + if (!Array.isArray(scene.placements) || !scene.placements.length) { + throw new Error(`Scene ${scene.id} requires at least one help placement.`); + } + ids.add(scene.id); +} diff --git a/recording/scenes/linearize-rri-layout.js b/recording/scenes/linearize-rri-layout.js new file mode 100644 index 0000000..efdb41a --- /dev/null +++ b/recording/scenes/linearize-rri-layout.js @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { openPanel } from './helpers.js'; + +const nucleotide = '#rendering-canvas circle[node_type="nucleotide"]'; +// An asymmetric bulge starts bent; the symmetric 10-node highlighting fixture +// already has horizontal paired rails before this option is enabled. +const pairs = [[1, 9], [2, 8], [4, 7], [5, 6]]; +const tolerance = 1e-6; + +const coordinates = page => page.locator(nucleotide).evaluateAll(elements => + elements.map(element => { + const { num, x, y } = element.__data__; + return { num, x, y }; + })); + +async function assertStatic(page) { + const before = await coordinates(page); + assert.deepEqual(before.map(node => node.num), [1, 2, 3, 4, 5, 6, 7, 8, 9]); + assert.ok(before.every(node => Number.isFinite(node.x) && Number.isFinite(node.y))); + await page.waitForTimeout(250); + assert.deepEqual(await coordinates(page), before, 'Force-off coordinates remain unchanged'); + assert.equal(await page.locator('#forceLayout').isChecked(), false); + assert.equal(await page.locator('#forceLayoutLinearStructure').isChecked(), false); + assert.equal(await page.locator('#highlighting').inputValue(), 'nothing'); + assert.equal(await page.locator('#backgroundhighlighting').inputValue(), 'nothing'); + assert.equal(await page.locator('#rendering-canvas [data-varri-text]').count(), 0); + assert.equal(await page.locator('#msg.error:visible').count(), 0); + return before; +} + +async function waitForHorizontalLayout(page, before) { + await page.waitForFunction(({ nucleotide, pairs, tolerance, before }) => { + const canvas = document.querySelector('#rendering-canvas'); + const elements = [...document.querySelectorAll(nucleotide)]; + if (canvas?.style.visibility === 'hidden' || elements.length !== 9 || + !document.querySelector('#forceLayoutLinearRRI')?.checked || + document.querySelector('#forceLayout')?.checked) return false; + const nodes = new Map(elements.map(element => [element.__data__.num, element.__data__])); + const upperY = nodes.get(1)?.y; + const lowerY = nodes.get(9)?.y; + return elements.every(element => { + const { x, y } = element.__data__; + const matrix = element.parentElement.transform.baseVal.consolidate()?.matrix; + // SVG transform matrices round to float precision; model rail checks below + // still require the tighter double-precision geometry tolerance. + return Number.isFinite(x) && Number.isFinite(y) && matrix && + Math.abs(matrix.e - x) < 0.001 && Math.abs(matrix.f - y) < 0.001; + }) && pairs.every(([a, b]) => { + const upper = nodes.get(a), lower = nodes.get(b); + return upper && lower && Math.abs(upper.y - upperY) < tolerance && + Math.abs(lower.y - lowerY) < tolerance && Math.abs(upper.x - lower.x) < tolerance && + Math.abs(Math.hypot(upper.x - lower.x, upper.y - lower.y) - 15) < tolerance; + }) && before.some(node => { + const current = nodes.get(node.num); + return Math.abs(node.x - current.x) > 0.1 || Math.abs(node.y - current.y) > 0.1; + }); + }, { nucleotide, pairs, tolerance, before }); +} + +export default { + id: 'linearize-rri-layout', title: 'Make the RRI horizontal', + stage: 'full-ui', zoom: false, + placements: ['docs:visualisation-settings', 'input-panel:linear-layout'], + parameters: { + sequence: 'GGAGG&CCCC', structure: '((.((&))))', textAnnotations: '[]', + forceLayout: '0', forceLayoutLinearRRI: '0', forceLayoutLinearStructure: '0', + highlighting: 'nothing', backgroundhighlighting: 'nothing', hideFooterAndHeader: '1', + }, + description: 'Enable Linear horizontal RRI layout to create stable paired rails while Force layout stays off.', + async prepare(page) { + await openPanel(page, '#forceLayoutLinearRRI'); + await page.addStyleTag({ content: ` + .label-dropdown-col-row { + grid-template-columns: 132px minmax(0, 1fr) 22px; + gap: 6px; + } + ` }); + }, + async run(capture, page) { + const linear = page.locator('#forceLayoutLinearRRI'); + assert.equal(await linear.isChecked(), false); + const before = await assertStatic(page); + const initialRail = pairs.map(([a]) => before.find(node => node.num === a).y); + assert.ok(Math.max(...initialRail) - Math.min(...initialRail) > 0.1, + 'The initial interaction is visibly bent before the real checkbox click'); + + await capture.caption('With Force layout off, enable Linear horizontal RRI layout'); + await capture.clearCaption(); + await capture.hold(2); + await capture.click(linear, undefined, () => waitForHorizontalLayout(page, before)); + + assert.equal(await linear.isChecked(), true); + const after = await assertStatic(page); + assert.notDeepEqual(after, before, 'The checkbox must change the actual graph coordinates'); + const nodes = new Map(after.map(node => [node.num, node])); + for (const [a, b] of pairs) { + const upper = nodes.get(a), lower = nodes.get(b); + assert.ok(Math.abs(upper.y - nodes.get(1).y) < tolerance, 'First paired rail is horizontal'); + assert.ok(Math.abs(lower.y - nodes.get(9).y) < tolerance, 'Second paired rail is horizontal'); + assert.ok(Math.abs(upper.x - lower.x) < tolerance, 'Each pair is vertically aligned'); + assert.ok(Math.abs(Math.hypot(upper.x - lower.x, upper.y - lower.y) - 15) < tolerance); + } + assert.equal(await page.locator('#sequence').inputValue(), 'GGAGG&CCCC'); + assert.equal(await page.locator('#structure').inputValue(), '((.((&))))'); + await capture.move({ x: 365, y: 160 }); + await capture.hold(1); + await capture.caption('Paired bases now form horizontal rails; Force layout stays off'); + await capture.clearCaption(); + await capture.hold(4); + }, +}; diff --git a/recording/scenes/move-selection.js b/recording/scenes/move-selection.js new file mode 100644 index 0000000..64af0e2 --- /dev/null +++ b/recording/scenes/move-selection.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { center } from '../lib/capture.js'; +import { node, near, selectPair } from './helpers.js'; + +export default { + id: 'move-selection', title: 'Move a selection', + placements: ['canvas-help:move', 'docs:additional-features'], + description: 'Select two nodes with Ctrl/Command-click, release the modifier, then drag either selected node. Both move together and retain their new positions.', + async run(capture, page) { + await selectPair(capture, page); + const before = await Promise.all([center(node(page, 3)), center(node(page, 4))]); + const unchanged = await center(node(page, 8)); + await capture.caption('Release Ctrl / ⌘, then drag either selected node'); + await capture.hold(2.4); + await capture.drag(node(page, 3), -90, -70); + const after = await Promise.all([center(node(page, 3)), center(node(page, 4))]); + for (let index = 0; index < 2; index++) { + near(after[index].x - before[index].x, -90); + near(after[index].y - before[index].y, -70); + } + const other = await center(node(page, 8)); + near(other.x, unchanged.x); + near(other.y, unchanged.y); + assert.match(await page.locator('#selectMovedBtn').textContent(), /Moved: 2/); + await capture.caption('Both nodes keep their new positions; other nodes stay put'); + await capture.hold(2.6); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.5); + const retained = await Promise.all([center(node(page, 3)), center(node(page, 4))]); + for (let index = 0; index < 2; index++) { + near(retained[index].x, after[index].x); + near(retained[index].y, after[index].y); + } + const stationary = await center(node(page, 8)); + near(stationary.x, unchanged.x); + near(stationary.y, unchanged.y); + }, +}; diff --git a/recording/scenes/name-and-number-strand.js b/recording/scenes/name-and-number-strand.js new file mode 100644 index 0000000..bb6ae4d --- /dev/null +++ b/recording/scenes/name-and-number-strand.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; + +const canvas = '#rendering-canvas'; +const name = `${canvas} [data-varri-sequence-name="1"] text`; +const labels = `${canvas} [label_type="label"]`; + +export default { + id: 'name-and-number-strand', title: 'Name and number a strand', + stage: 'full-ui', zoom: false, + placements: ['docs:sequence-and-structure-input-fields', 'input-panel:numbering'], + parameters: { + sequence: 'GGAAUU&AAUUCC', structure: '((....&....))', + forceLayout: '0', startIndex1: '1', startIndex2: '1', hideFooterAndHeader: '1', + }, + description: 'Commit a strand name and a negative biological start index, with numbering that skips zero.', + async run(capture, page) { + assert.equal(await page.locator('#seqName1').inputValue(), 'RNA 1'); + assert.equal(await page.locator('#startIndex1').inputValue(), '1'); + assert.equal(await page.locator(name).textContent(), 'RNA 1'); + assert.equal(await page.locator(labels).count(), 4); + + await capture.caption('Name the first strand Leader RNA, then press Enter'); + await capture.fill(page.locator('#seqName1'), 'Leader RNA'); + await capture.press('Enter', undefined, () => page.waitForFunction(() => + document.querySelector('#seqName1')?.value === 'Leader RNA' && + document.querySelector('#rendering-canvas [data-varri-sequence-name="1"] text') + ?.textContent === 'Leader RNA' && + document.querySelectorAll('#rendering-canvas [label_type="label"]').length === 4)); + await capture.move({ x: 365, y: 160 }); + await capture.hold(1.5); + + await capture.caption('Set Start 1 to -2, then press Enter'); + await capture.fill(page.locator('#startIndex1'), '-2'); + await capture.press('Enter', undefined, () => page.waitForFunction(() => { + const drawing = document.querySelector('#rendering-canvas'); + const values = [...drawing.querySelectorAll('[label_type="label"]')] + .map(label => label.textContent); + return document.querySelector('#startIndex1')?.value === '-2' && + drawing.style.visibility !== 'hidden' && + drawing.querySelectorAll('circle[node_type="nucleotide"]').length === 12 && + drawing.querySelector('[data-varri-sequence-name="1"] text')?.textContent === 'Leader RNA' && + JSON.stringify(values) === JSON.stringify(['-2', '1', '4', '1', '6']); + })); + assert.deepEqual(await page.locator(`${canvas} circle[node_type="nucleotide"] title`) + .evaluateAll(titles => titles.slice(0, 6).map(title => title.textContent)), + ['s1[-2]', 's1[-1]', 's1[1]', 's1[2]', 's1[3]', 's1[4]']); + assert.equal(await page.locator('#sequence').inputValue(), 'GGAAUU&AAUUCC'); + assert.equal(await page.locator('#structure').inputValue(), '((....&....))'); + assert.equal(await page.locator('#msg.error:visible').count(), 0); + await capture.move({ x: 365, y: 160 }); + await capture.hold(1.5); + await capture.caption('Numbering skips zero between -1 and 1'); + await capture.clearCaption(); + await capture.hold(3); + }, +}; diff --git a/recording/scenes/place-text-annotation.js b/recording/scenes/place-text-annotation.js new file mode 100644 index 0000000..4c16448 --- /dev/null +++ b/recording/scenes/place-text-annotation.js @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { openPanel } from './helpers.js'; + +export default { + id: 'place-text-annotation', title: 'Place a text annotation', stage: 'full-ui', zoom: false, + placements: ['docs:text-annotations'], + parameters: { hideFooterAndHeader: '1' }, + description: 'Create a text label, then drag its unpositioned list entry onto the drawing.', + async prepare(page) { await openPanel(page, '#textAnnotationSubmitBtn'); }, + async run(capture, page) { + await capture.caption('Open the text annotation dialog'); + await capture.click(page.locator('#textAnnotationSubmitBtn')); + await page.locator('#textAnnotationDialog').waitFor({ state: 'visible' }); + + await capture.caption('Enter Binding site as the annotation text'); + await capture.fill(page.locator('#textAnnotationText'), 'Binding site'); + assert.equal(await page.locator('#textAnnotationText').inputValue(), 'Binding site'); + + await capture.caption('Turn on Bold to emphasize the label'); + await capture.click(page.locator('#textAnnotationBold')); + assert.equal(await page.locator('#textAnnotationBold').isChecked(), true); + + await capture.caption('Click OK to add the label to the list'); + await capture.click(page.locator('#textAnnotationDialog button[value="ok"]')); + await page.locator('#textAnnotationDialog').waitFor({ state: 'hidden' }); + const item = page.locator('.text-annotation-item').filter({ hasText: 'Binding site' }); + assert.equal(await item.locator('[aria-label="Unpositioned"]').count(), 1); + const label = page.locator('#rendering-canvas [data-varri-text] text').filter({ hasText: 'Binding site' }); + assert.equal(await label.count(), 0, 'The label stays off the canvas until placed'); + + const canvas = await page.locator('#rendering-canvas').boundingBox(); + const destination = { x: canvas.x + canvas.width * 0.56, y: canvas.y + canvas.height * 0.28 }; + await capture.caption('The ? means unpositioned. Drag the label onto the drawing'); + await capture.dragTo(item.locator('.text-annotation-preview'), destination); + await page.waitForFunction(() => [...document.querySelectorAll('.text-annotation-item')] + .some(item => item.textContent.includes('Binding site') && item.querySelector('[aria-label="Positioned"]'))); + assert.equal(await label.count(), 1, 'Exactly one label is drawn after the drop'); + assert.equal(await label.getAttribute('font-weight'), 'bold'); + const placed = await label.evaluate(element => { + const transform = element.closest('[data-varri-text]').getScreenCTM(); + return { x: transform.e, y: transform.f }; + }); + assert.ok(Math.hypot(placed.x - destination.x, placed.y - destination.y) < 1, + 'The annotation is positioned at the actual pointer drop location'); + assert.equal(await item.locator('[aria-label="Unpositioned"]').count(), 0); + + await capture.caption('The target icon confirms the label is positioned'); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.5); + }, +}; diff --git a/recording/scenes/rectangle-selection.js b/recording/scenes/rectangle-selection.js new file mode 100644 index 0000000..c8d8b7a --- /dev/null +++ b/recording/scenes/rectangle-selection.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { node, selected } from './helpers.js'; + +export default { + id: 'rectangle-selection', title: 'Select with a rectangle', + placements: ['canvas-help:select', 'docs:additional-features'], + description: 'Hold Ctrl or Command and drag the canvas background around nodes to replace the selection.', + async run(capture, page) { + assert.equal(await selected(page), 0, 'Start without a selection'); + await capture.caption('Ctrl / ⌘ + click a node to start a selection'); + await capture.click(node(page, 0), 'Control'); + assert.equal(await selected(page), 1, 'The first click selects one node'); + assert.equal(await node(page, 0).evaluate(element => element.__data__.selected), true); + const boxes = await Promise.all([3, 4].map(index => node(page, index).boundingBox())); + const start = { x: Math.min(...boxes.map(box => box.x)) - 25, + y: Math.min(...boxes.map(box => box.y)) - 25 }; + const end = { x: Math.max(...boxes.map(box => box.x + box.width)) + 25, + y: Math.max(...boxes.map(box => box.y + box.height)) + 25 }; + await capture.caption('Ctrl / ⌘ + drag the background around two other nodes'); + await capture.dragBetween(start, end, 'Control'); + assert.equal(await selected(page), 2, 'The rectangle must select the two enclosed nodes'); + assert.equal(await node(page, 0).evaluate(element => element.__data__.selected), false, + 'The previously selected node outside the rectangle must be deselected'); + for (const index of [3, 4]) { + assert.equal(await node(page, index).evaluate(element => element.__data__.selected), true, + 'Each enclosed node must actually be selected'); + } + await capture.move({ x: 80, y: 140 }); + await capture.caption('The rectangle replaces the selection with these two nodes'); + await capture.clearCaption(); + await capture.hold(1.8); + }, +}; diff --git a/recording/scenes/release-positions.js b/recording/scenes/release-positions.js new file mode 100644 index 0000000..e13352d --- /dev/null +++ b/recording/scenes/release-positions.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { node, selected } from './helpers.js'; + +const position = target => target.evaluate(element => { + const { x, y, fixed, fx, fy } = element.__data__; + return { x, y, fixed, fx, fy }; +}); + +async function settleLayout(page) { + const coordinates = () => page.locator('#rendering-canvas circle[node_type="nucleotide"]') + .evaluateAll(elements => elements.map(element => [element.__data__.x, element.__data__.y])); + let previous = await coordinates(); + let stable = 0; + for (let attempt = 0; attempt < 100; attempt++) { + await page.waitForTimeout(100); + const current = await coordinates(); + stable = current.every(([x, y], index) => + Math.hypot(x - previous[index][0], y - previous[index][1]) < 0.001) ? stable + 1 : 0; + if (stable >= 5) return; + previous = current; + } + assert.fail('The initial Force layout must settle before the recorded drag'); +} + +export default { + id: 'release-positions', title: 'Release a moved node', + zoom: false, + placements: ['canvas-help:release', 'docs:additional-features'], + parameters: { forceLayout: '1', textAnnotations: '[]' }, + description: 'With Force layout enabled, drag a node, open View controls, select it through Moved, and use Release to remove its manual fixation.', + async prepare(page) { + await settleLayout(page); + const canvas = await page.locator('#rendering-canvas').boundingBox(); + await page.mouse.move(canvas.x + canvas.width / 2, canvas.y + canvas.height / 2); + await page.mouse.wheel(0, -250); + await page.waitForTimeout(250); + await page.mouse.move(35, 110); + }, + async run(capture, page) { + assert.equal(await page.locator('#forceLayout').isChecked(), true); + const original = await position(node(page, 3)); + await capture.caption('With Force layout enabled, drag a node to fix its position'); + await capture.hold(2); + await capture.drag(node(page, 3), -90, -70); + const moved = await position(node(page, 3)); + assert.ok(Math.hypot(moved.x - original.x, moved.y - original.y) > 1, + 'The real drag must move the node'); + assert.ok(moved.fixed && moved.fx != null && moved.fy != null, + 'Dragging must fix the node before Release'); + assert.match(await page.locator('#selectMovedBtn').textContent(), /Moved: 1/); + assert.equal(await page.locator('#viewControlsToggle').getAttribute('aria-expanded'), 'false'); + await capture.caption('Open View controls on the right'); + await capture.click(page.locator('#viewControlsToggle')); + assert.equal(await page.locator('#viewControlsToggle').getAttribute('aria-expanded'), 'true'); + await capture.caption('Click Moved to select the fixed node'); + await capture.hold(1.8); + await capture.click(page.locator('#selectMovedBtn')); + assert.equal(await selected(page), 1); + assert.equal(await page.locator('#releasePositionsBtn').isDisabled(), false); + await capture.caption('Click Release to remove the node’s manual fixation'); + await capture.hold(2); + await capture.click(page.locator('#releasePositionsBtn')); + assert.match(await page.locator('#selectMovedBtn').textContent(), /Moved: 0/); + assert.equal(await page.locator('#resetPositionsBtn').isDisabled(), true); + const released = await position(node(page, 3)); + assert.equal(Boolean(released.fixed || released.fx != null || released.fy != null), false); + assert.ok(Number.isFinite(released.x) && Number.isFinite(released.y)); + await capture.caption('Manual fixation is removed; Moved returns to 0'); + await capture.hold(2); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.3); + }, +}; diff --git a/recording/scenes/reset-positions.js b/recording/scenes/reset-positions.js new file mode 100644 index 0000000..6a62bfd --- /dev/null +++ b/recording/scenes/reset-positions.js @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { center } from '../lib/capture.js'; +import { node, selected, near } from './helpers.js'; + +export default { + id: 'reset-positions', title: 'Reset moved nodes', + placements: ['canvas-help:reset', 'canvas-help:undo', 'docs:additional-features'], + description: 'Drag a node, open View controls, and click Moved to select manually positioned nodes. Reset restores their original positions; Undo reverses the reset.', + async run(capture, page) { + const original = await center(node(page, 3)); + const untouched = await center(node(page, 9)); + await capture.caption('Drag a node to a new position'); + await capture.hold(1); + await capture.drag(node(page, 3), -90, -70); + const moved = await center(node(page, 3)); + near(moved.x - original.x, -90); + near(moved.y - original.y, -70); + assert.match(await page.locator('#selectMovedBtn').textContent(), /Moved: 1/); + assert.equal(await page.locator('#viewControlsToggle').getAttribute('aria-expanded'), 'false'); + await capture.caption('Open View controls to find Moved, Reset, and Undo'); + await capture.click(page.locator('#viewControlsToggle')); + assert.equal(await page.locator('#viewControlsToggle').getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator('#viewControlsPanel').isVisible(), true); + await capture.caption('Click Moved to select the node you dragged'); + await capture.click(page.locator('#selectMovedBtn')); + assert.equal(await selected(page), 1); + assert.equal(await node(page, 3).evaluate(element => element.__data__.selected), true); + await capture.caption('Click Reset to restore its original position'); + await capture.click(page.locator('#resetPositionsBtn')); + const reset = await center(node(page, 3)); + near(reset.x, original.x); + near(reset.y, original.y); + assert.match(await page.locator('#selectMovedBtn').textContent(), /Moved: 0/); + await capture.hold(0.8); + await capture.caption('Click Undo to return to the dragged position'); + await capture.click(page.locator('#undoCanvasBtn')); + const restored = await center(node(page, 3)); + near(restored.x, moved.x); + near(restored.y, moved.y); + assert.match(await page.locator('#selectMovedBtn').textContent(), /Moved: 1/); + const stillUntouched = await center(node(page, 9)); + near(stillUntouched.x, untouched.x); + near(stillUntouched.y, untouched.y); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.2); + }, +}; diff --git a/recording/scenes/rotate-selection.js b/recording/scenes/rotate-selection.js new file mode 100644 index 0000000..e1007b8 --- /dev/null +++ b/recording/scenes/rotate-selection.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { center } from '../lib/capture.js'; +import { node, near, selectPair, selected } from './helpers.js'; + +export default { + id: 'rotate-selection', title: 'Rotate a selection', + placements: ['canvas-help:rotate', 'docs:additional-features'], + description: 'Select at least two elements, then hold Ctrl or Command and scroll to rotate them around the pointer.', + async run(capture, page) { + await selectPair(capture, page); + // An existing nucleotide provides a visible pivot with room for the rotated pair. + const pivotTarget = node(page, 2); + const pivot = await center(pivotTarget); + const before = await Promise.all([center(node(page, 3)), center(node(page, 4))]); + const stationary = await center(node(page, 12)); + await capture.caption('Hold Ctrl / ⌘ and scroll to rotate around the pointer'); + await capture.hold(2); + await capture.wheel(pivotTarget, 240, 'Control'); + await page.waitForTimeout(250); + const after = await Promise.all([center(node(page, 3)), center(node(page, 4))]); + assert.ok(after.some((point, index) => Math.hypot(point.x - before[index].x, + point.y - before[index].y) > 8), 'Selected nodes must visibly rotate'); + const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + after.forEach((point, index) => near(distance(point, pivot), distance(before[index], pivot))); + near(distance(after[0], after[1]), distance(before[0], before[1])); + const unchanged = await center(node(page, 12)); + near(unchanged.x, stationary.x); near(unchanged.y, stationary.y); + assert.equal(await selected(page), 2, 'The rotated nodes remain selected'); + assert.match(await page.locator('#selectMovedBtn').textContent(), /Moved: 2/); + await capture.hold(0.8); + await capture.caption('Both nodes keep their rotated positions'); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.3); + const retained = await Promise.all([center(node(page, 3)), center(node(page, 4))]); + retained.forEach((point, index) => { + near(point.x, after[index].x); near(point.y, after[index].y); + }); + }, +}; diff --git a/recording/scenes/select-nodes.js b/recording/scenes/select-nodes.js new file mode 100644 index 0000000..47b652f --- /dev/null +++ b/recording/scenes/select-nodes.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { node, selected } from './helpers.js'; + +export default { + id: 'select-nodes', title: 'Select nodes', + placements: ['canvas-help:select', 'docs:additional-features'], + description: 'Hold Ctrl (Command on macOS) and click nodes to add them to the selection. Click a selected node with the same modifier to remove it.', + async run(capture, page) { + await capture.caption('Hold Ctrl / ⌘ and click two nodes to select them'); + await capture.hold(2); + await capture.click(node(page, 3), 'Control'); + assert.equal(await selected(page), 1, 'The first click must select one node'); + await capture.click(node(page, 4), 'Control'); + assert.equal(await selected(page), 2, 'The second click must add another node'); + await capture.hold(0.6); + await capture.caption('Keep holding Ctrl / ⌘ and click another node to add it'); + await capture.hold(2); + await capture.click(node(page, 5), 'Control'); + assert.equal(await selected(page), 3, 'The third node must join the selection'); + await capture.hold(0.7); + await capture.caption('Ctrl / ⌘ + click a selected node again to remove it'); + await capture.hold(2); + await capture.click(node(page, 4), 'Control'); + assert.equal(await selected(page), 2, 'Removing one node must leave two selected'); + assert.equal(await node(page, 4).getAttribute('data-varri-selected'), null, + 'The clicked node must be deselected'); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.5); + }, +}; diff --git a/recording/scenes/store-load-session.js b/recording/scenes/store-load-session.js new file mode 100644 index 0000000..b4fe941 --- /dev/null +++ b/recording/scenes/store-load-session.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { ready } from './helpers.js'; + +export default { + id: 'store-load-session', title: 'Store and load a session', stage: 'full-ui', zoom: false, + placements: ['docs:saved-session', 'docs:json-session'], + parameters: { hideFooterAndHeader: '1', sequence: 'ACGU&UGCA', structure: '((..&..))', seqName1: 'Saved RNA' }, + description: 'Download the current JSON session, clear the viewer, and load the file to restore the figure.', + async run(capture, page) { + await capture.caption('Click Store to download this session as a JSON file'); + const downloadPromise = page.waitForEvent('download'); + await capture.click(page.locator('#storeJsonBtn')); + const download = await downloadPromise; + const stored = await download.path(); + assert.ok(stored); + assert.equal(download.suggestedFilename(), 'vaRRI_session.json'); + const session = JSON.parse(await fs.readFile(stored, 'utf8')); + assert.equal(session.format, 'vaRRI'); + assert.equal(session.version, 1); + assert.equal(session.fields.sequence, 'ACGU&UGCA'); + assert.equal(session.fields.structure, '((..&..))'); + assert.equal(session.fields.seqName1, 'Saved RNA'); + await capture.caption('Click Clear to empty the inputs and remove the figure'); + await capture.click(page.locator('#clearAllBtn')); + assert.equal(await page.locator('#sequence').inputValue(), ''); + assert.equal(await page.locator('#structure').inputValue(), ''); + assert.equal(await page.locator('#rendering-canvas circle[node_type="nucleotide"]').count(), 0); + await capture.hold(1.2); + await capture.caption('Click Load, then choose vaRRI_session.json in the file picker'); + const chooserPromise = page.waitForEvent('filechooser'); + await capture.click(page.locator('#loadJsonBtn')); + const chooser = await chooserPromise; + await chooser.setFiles(stored); + await ready(page); + await page.waitForFunction(() => document.getElementById('seqName1')?.value === 'Saved RNA'); + for (const field of ['sequence', 'structure', 'seqName1']) { + assert.equal(await page.locator(`#${field}`).inputValue(), session.fields[field]); + } + assert.equal(await page.locator('#rendering-canvas circle[node_type="nucleotide"]').count(), 8); + assert.equal(await page.locator('#msg.error:visible').count(), 0); + await capture.move({ x: 80, y: 140 }); + await capture.hold(1.5); + await capture.caption('The saved inputs and RNA figure are restored'); + await capture.clearCaption(); + await capture.hold(1.5); + }, +}; diff --git a/scripts/browser-canvas-help.js b/scripts/browser-canvas-help.js index f5fdb95..d4255ca 100644 --- a/scripts/browser-canvas-help.js +++ b/scripts/browser-canvas-help.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { exerciseCanvasHelpVideoTeaser } from './browser-help-videos.js'; /** The panel must supply working help even when its host knows only ViewerPanel. */ export async function exerciseStandaloneCanvasHelp(page, origin) { @@ -27,6 +28,17 @@ export async function exerciseStandaloneCanvasHelp(page, origin) { assert.equal(await dialog.count(), 1, 'The help button supplies exactly one accessible dialog'); assert.equal(await dialog.isVisible(), true, 'Keyboard activation opens visible help'); assert.equal(await dialog.evaluate(element => element.matches(':modal')), true, 'Help enters the modal top layer'); + await page.waitForFunction(() => document.querySelectorAll('#canvasHelpDialog [data-help-demo-ready]').length === 6); + await exerciseCanvasHelpVideoTeaser(page); + const demoTrigger = dialog.locator('[data-help-demo="move-selection"] .help-demo-trigger'); + await demoTrigger.focus(); + await dialog.locator('[data-help-demo="move-selection"] .help-demo-teaser').waitFor({ state: 'visible' }); + await demoTrigger.press('Enter'); + const video = dialog.locator('[data-help-demo="move-selection"] video'); + assert.equal(await video.isVisible(), true, 'Enter expands the requested demonstration'); + assert.equal(await video.getAttribute('autoplay'), null); + assert.equal(await video.getAttribute('loop'), null); + assert.equal(await video.locator('track[kind="captions"]').count(), 1); await checkCanvasHelpStyles(page); await page.keyboard.press('Escape'); assert.equal(await help.evaluate(element => element === document.activeElement), true); @@ -44,7 +56,7 @@ export async function checkCanvasHelpStyles(page) { const dialog = page.locator('#canvasHelpDialog'); const box = await dialog.boundingBox(); const viewport = page.viewportSize(); - assert.ok(box.width <= 542 && box.width <= viewport.width - 30, 'Help has a bounded width'); + assert.ok(box.width <= 722 && box.width <= viewport.width - 30, 'Help has a bounded width'); assert.ok(Math.abs(box.x + box.width / 2 - viewport.width / 2) < 2, 'Help is horizontally centered'); assert.ok(Math.abs(box.y + box.height / 2 - viewport.height / 2) < 2, 'Help is vertically centered'); assert.equal(await dialog.locator('dt').first().evaluate(el => getComputedStyle(el).fontWeight), '700'); diff --git a/scripts/browser-example-dropdown.js b/scripts/browser-example-dropdown.js index bf00a09..37583e0 100644 --- a/scripts/browser-example-dropdown.js +++ b/scripts/browser-example-dropdown.js @@ -7,8 +7,11 @@ export async function checkExampleDropdownOverlay(page) { const sequence = page.locator('#sequence'); const before = await sequence.boundingBox(); + // The native open property changes before its queued toggle event updates Vue. + const opened = dropdown.evaluate(element => new Promise(resolve => + element.addEventListener('toggle', () => resolve(element.open), { once: true }))); await trigger.click(); - await page.waitForFunction(() => document.getElementById('exampleDropdown')?.open); + assert.equal(await opened, true, 'The open toggle has reached the application'); const after = await sequence.boundingBox(); const optionBounds = await options.boundingBox(); diff --git a/scripts/browser-help-videos.js b/scripts/browser-help-videos.js new file mode 100644 index 0000000..c783258 --- /dev/null +++ b/scripts/browser-help-videos.js @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +async function visibleTeaser(target) { + const teaser = target.locator('.help-demo-teaser'); + await teaser.waitFor({ state: 'visible' }); + await target.page().waitForFunction(element => { + const image = element.querySelector('img'); + return image.complete && image.naturalWidth > 0; + }, await teaser.elementHandle()); + return teaser; +} + +async function assertPreviewFits(teaser, dialog) { + const preview = await teaser.boundingBox(); + const bounds = dialog ? await dialog.boundingBox() : + { x: 0, y: 0, ...teaser.page().viewportSize() }; + assert.ok(preview.x >= bounds.x && preview.y >= bounds.y && + preview.x + preview.width <= bounds.x + bounds.width && + preview.y + preview.height <= bounds.y + bounds.height, + 'The entire preview remains inside its visible viewport'); +} + +export async function exerciseDocsHelpVideos(page) { + const catalog = await page.evaluate(async () => (await fetch('doc/demos/manifest.json')).json()); + const headingForPlacement = { + 'fasta-input': 'fasta-sequence-and-structure-input', + 'saved-session': 'load-a-saved-session', + 'json-session': 'json-session-files', + }; + for (const clip of catalog.clips) { + const demos = page.locator(`[data-help-demo="${clip.id}"]`); + assert.ok(await demos.count() >= 1, `Documentation includes ${clip.id}`); + for (const demo of await demos.all()) { + assert.equal(await demo.getAttribute('data-help-demo-ready'), 'true'); + assert.ok((await demo.locator('button').getAttribute('aria-label')).includes(clip.title), + 'Each demonstration has a task-specific accessible name'); + } + for (const placement of clip.placements.filter(value => value.startsWith('docs:'))) { + const name = placement.slice(5); + const anchorId = headingForPlacement[name] || name; + const appearsInSection = await page.evaluate(({ anchorId, id }) => { + const heading = document.getElementById(anchorId); + if (!heading) return false; + const selector = `[data-help-demo="${id}"]`; + for (let sibling = heading.nextElementSibling; sibling; sibling = sibling.nextElementSibling) { + if (/^H[1-6]$/.test(sibling.tagName) && sibling.tagName <= heading.tagName) break; + if (sibling.matches(selector) || sibling.querySelector(selector)) return true; + } + return false; + }, { anchorId, id: clip.id }); + assert.equal(appearsInSection, true, `The ${clip.id} demo accompanies its ${placement} instructions`); + } + } + + const move = page.locator('[data-help-demo="move-selection"]').first(); + const trigger = move.locator('button'); + await trigger.focus(); + const teaser = await visibleTeaser(move); + await assertPreviewFits(teaser); + await page.keyboard.press('Escape'); + await teaser.waitFor({ state: 'hidden' }); + assert.equal(await trigger.evaluate(element => element === document.activeElement), true, + 'Escape dismisses a preview without moving keyboard focus'); + await trigger.press('Enter'); + const moveVideo = move.locator('video'); + assert.equal(await moveVideo.getAttribute('aria-hidden'), null, + 'The full, controlled player remains available to assistive technology'); + await moveVideo.evaluate(video => video.play()); + await page.waitForFunction(video => video.currentTime > 0 && video.textTracks.length === 1 && + video.textTracks[0].cues?.length > 0 && video.textTracks[0].mode === 'showing', + await moveVideo.elementHandle()); + const reset = page.locator('[data-help-demo="reset-positions"]').first(); + await reset.locator('button').click(); + await reset.locator('video').evaluate(video => video.play()); + assert.equal(await moveVideo.evaluate(video => video.paused), true, + 'Playing another demonstration pauses the first one'); + await reset.locator('button').click(); + await page.waitForFunction(video => video.paused && video.currentTime === 0, + await reset.locator('video').elementHandle()); + await move.locator('button').click(); +} + +export async function exerciseActionVideoPreviews(page, output) { + const expected = { + selectMovedBtn: 'release-positions', releasePositionsBtn: 'release-positions', + resetPositionsBtn: 'reset-positions', undoCanvasBtn: 'reset-positions', + exportPngBtn: 'export-png', storeJsonBtn: 'store-load-session', + }; + await page.waitForFunction(count => document.querySelectorAll('[data-help-preview-ready="true"]').length === count, + Object.keys(expected).length); + for (const [id, clip] of Object.entries(expected)) { + const button = page.locator('#' + id); + assert.equal(await button.getAttribute('data-help-preview'), clip, `${id} references its corresponding clip`); + assert.equal(await button.getAttribute('data-help-preview-ready'), 'true'); + assert.ok((await button.getAttribute('aria-describedby'))?.startsWith('help-action-preview-')); + const icon = await button.evaluate(element => getComputedStyle(element, '::after').content); + assert.notEqual(icon, 'none', `${id} displays the video affordance`); + } + + const png = page.locator('#exportPngBtn'); + const pngPreview = page.locator('.help-action-preview[data-help-for="export-png"]'); + assert.equal(await pngPreview.locator('video').count(), 0, 'The WebM player stays lazy before hover'); + await png.hover(); + await pngPreview.waitFor({ state: 'visible' }); + const video = pngPreview.locator('video'); + await page.waitForFunction(media => media.currentTime > 0 && !media.paused, await video.elementHandle()); + assert.equal(await video.evaluate(media => media.muted && media.loop && media.playsInline), true, + 'The hover preview plays silently inline'); + assert.equal(await video.getAttribute('aria-hidden'), 'true', + 'The decorative preview leaves its written description to assistive technology'); + await assertPreviewFits(pngPreview); + if (output) await page.screenshot({ path: path.join(output, 'action-video-preview.png'), fullPage: true }); + await page.mouse.move(0, 0); + await pngPreview.waitFor({ state: 'hidden' }); + assert.equal(await video.evaluate(media => media.paused && media.currentTime === 0), true, + 'Leaving the button stops and rewinds its preview'); + + const store = page.locator('#storeJsonBtn'); + const storePreview = page.locator('.help-action-preview[data-help-for="store-load-session"]'); + await store.focus(); + await storePreview.waitFor({ state: 'visible' }); + await page.keyboard.press('Escape'); + await storePreview.waitFor({ state: 'hidden' }); + assert.equal(await store.evaluate(element => element === document.activeElement), true, + 'Escape dismisses keyboard video help without moving focus'); + + const drawer = page.locator('#viewControlsPanel'); + const toggle = page.locator('#viewControlsToggle'); + if (!await drawer.isVisible()) await toggle.click(); + for (const id of ['selectMovedBtn', 'releasePositionsBtn', 'resetPositionsBtn', 'undoCanvasBtn']) { + const target = page.locator('#' + id); + const previewId = (await target.getAttribute('aria-describedby')).split(' ') + .find(value => value.startsWith('help-action-preview-')); + const preview = page.locator('#' + previewId); + await target.hover(); + await preview.waitFor({ state: 'visible' }); + const media = preview.locator('video'); + await page.waitForFunction(video => !video.paused && video.currentTime > 0, await media.elementHandle()); + await assertPreviewFits(preview); + await toggle.click(); + await preview.waitFor({ state: 'hidden' }); + assert.equal(await media.evaluate(video => video.paused && video.currentTime === 0), true, + 'Folding the right drawer stops and resets its video preview'); + await toggle.click(); + } +} + +export async function exerciseCanvasHelpVideoTeaser(page) { + const previousViewport = page.viewportSize(); + await page.setViewportSize({ width: 1000, height: 700 }); + const dialog = page.locator('#canvasHelpDialog'); + const target = dialog.locator('[data-help-demo="reset-positions"]'); + const trigger = target.locator('button'); + try { + await trigger.focus(); + const teaser = await visibleTeaser(target); + await assertPreviewFits(teaser, dialog); + const footer = await dialog.locator('.dialog-actions').boundingBox(); + const preview = await teaser.boundingBox(); + assert.ok(preview.y + preview.height <= footer.y, 'The preview does not cover the dialog Close action'); + await page.keyboard.press('Escape'); + await teaser.waitFor({ state: 'hidden' }); + assert.equal(await dialog.evaluate(element => element.open), true, + 'The first Escape dismisses the preview without closing the help dialog'); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + await page.locator('#canvasHelpBtn').click(); + assert.equal(await teaser.isVisible(), false, 'A dismissed preview stays closed when help reopens'); + + // Mouse users must be able to move from the trigger into the preview. + await trigger.hover(); + await visibleTeaser(target); + await teaser.hover(); + assert.equal(await teaser.isVisible(), true, 'Hovering the preview keeps it available'); + await page.keyboard.press('Escape'); + await teaser.waitFor({ state: 'hidden' }); + await page.mouse.move(0, 0); + await page.setViewportSize({ width: 1000, height: 480 }); + await dialog.locator('#canvasHelpTitle').focus(); + await trigger.focus(); + await visibleTeaser(target); + await assertPreviewFits(teaser, dialog); + const shortFooter = await dialog.locator('.dialog-actions').boundingBox(); + const focusedButton = await trigger.boundingBox(); + assert.ok(focusedButton.y + focusedButton.height <= shortFooter.y, + 'Keyboard focus scrolls the trigger clear of the sticky footer'); + assert.equal(await teaser.evaluate(element => element.scrollHeight <= element.clientHeight + 1), true, + 'The full preview description is readable without scrolling a tooltip'); + await page.keyboard.press('Escape'); + } finally { + await page.mouse.move(0, 0); + await page.setViewportSize(previousViewport); + } +} diff --git a/scripts/browser-left-video-guides.js b/scripts/browser-left-video-guides.js new file mode 100644 index 0000000..f5d8270 --- /dev/null +++ b/scripts/browser-left-video-guides.js @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +const guides = { + 'Sequence and structure input': { + 'choose-example': '#exampleDropdownLabel', 'name-and-number-strand': '.sequence-names legend', + 'import-fasta': '#fastaInputBtn', 'store-load-session': '#loadJsonBtn', + }, + 'Visualization settings': { + 'highlight-rri-nucleotides': 'label[for="highlighting"]', + 'linearize-rri-layout': 'label[for="forceLayoutLinearRRI"]', + }, + 'Text annotations': { 'place-text-annotation': '#textAnnotationSubmitBtn' }, + 'Region highlights': { + 'highlight-rri-background': 'label[for="backgroundhighlighting"]', 'add-region-highlight': '#regionSubmitBtn', + }, + 'Subsequence highlights': { 'add-subsequence-highlight': '#highlightSubmitBtn' }, + 'Point mutations': { 'add-point-mutation': '#mutationSubmitBtn' }, + 'Probability profiles': { 'apply-probability-profile': '#profileApplyBtn' }, +}; + +const popupFor = async target => target.page().locator('#' + await target.getAttribute('aria-controls')); +const fieldValues = page => page.locator('.controls-column input, .controls-column textarea, .controls-column select') + .evaluateAll(fields => fields.map(field => [field.id, field.value, field.checked ?? null])); +const playing = async video => video.page().waitForFunction(element => !element.paused && element.currentTime > 0 && + element.textTracks[0]?.cues?.length > 0, await video.elementHandle()); + +async function stopped(video) { + await video.page().waitForFunction(element => element.paused && element.currentTime === 0, await video.elementHandle()); +} + +async function fits(popup) { + const box = await popup.boundingBox(), viewport = popup.page().viewportSize(); + assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= viewport.width + 1 && + box.y + box.height <= viewport.height + 1, 'The entire video popup fits the viewport'); +} + +async function touchVideo(page, output) { + const browser = page.context().browser(); + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, + ...(browser.browserType().name() === 'chromium' ? { isMobile: true } : {}) }); + try { + const touch = await context.newPage(); + const origin = new URL(page.url()).origin; + await touch.route('**/*', route => new URL(route.request().url()).origin === origin + ? route.continue() : route.fulfill({ status: 200, body: '' })); + await touch.goto(page.url(), { waitUntil: 'networkidle' }); + const initial = await fieldValues(touch); + const panel = touch.getByRole('complementary', { name: 'Point mutations', exact: true }); + await panel.locator('details.control-group > summary').tap(); + const target = panel.locator('[data-help-video="add-point-mutation"]'); + await target.tap(); + const popup = await popupFor(target), video = popup.locator('video'); + await playing(video); + await fits(popup); + assert.equal(await video.getAttribute('controls'), ''); + assert.equal(await touch.locator('#mutationDialog').isVisible(), false, 'Tapping help does not add a mutation'); + // A fixed popup belongs to the visible viewport. Full-page capture can + // change mobile scroll/viewport geometry and dismiss its anchored preview. + if (output) await touch.screenshot({ path: path.join(output, 'left-video-guides-mobile.png') }); + assert.equal(await popup.isVisible(), true, 'The mobile preview remains open after its viewport capture'); + await popup.getByRole('button', { name: 'Close video guide' }).tap(); + await stopped(video); + assert.deepEqual(await fieldValues(touch), initial); + } finally { await context.close(); } +} + +export async function exerciseLeftVideoGuides(page, output) { + const initial = await fieldValues(page); + const mediaRequests = []; + const record = request => { if (/\.webm(?:\?|$)/.test(request.url())) mediaRequests.push(request.url()); }; + page.on('request', record); + let first = true; + try { + await page.waitForFunction(() => document.querySelectorAll('[data-help-video-ready]').length === 12); + assert.equal(await page.locator('.panel-video-guides').count(), 0, 'Icons replace the separate guide lists'); + for (const [name, clips] of Object.entries(guides)) { + const panel = page.getByRole('complementary', { name, exact: true }); + const section = panel.locator('details.control-group'); + if (!await section.evaluate(element => element.open)) await section.locator(':scope > summary').click(); + assert.equal(await panel.locator('[data-help-video]').count(), Object.keys(clips).length); + for (const [id, selector] of Object.entries(clips)) { + const target = panel.locator(`[data-help-video="${id}"]`); + const popup = await popupFor(target); + await target.scrollIntoViewIfNeeded(); + const iconBox = await target.boundingBox(); + const controlBox = await panel.locator(selector).last().boundingBox(); + assert.ok(iconBox.width <= 32 && iconBox.height <= 32, `${id}: a compact video icon`); + assert.ok(Math.abs(iconBox.y - controlBox.y) < 40, `${id}: icon accompanies the matching control`); + assert.equal(await popup.locator('video').count(), 0, `${id}: video is created only on demand`); + assert.match(await target.getAttribute('aria-label'), /^Video guide: /); + if (first) assert.equal(mediaRequests.length, 0, 'No WebM loads before a video icon is used'); + + await target.hover(); + await popup.waitFor({ state: 'visible' }); + const video = popup.locator('video'); + await playing(video); + assert.ok(new URL(await video.locator('source').getAttribute('src')).pathname.endsWith(`/${id}.webm`), + `${id}: the icon plays its corresponding video`); + assert.equal(await video.evaluate(element => element.muted && element.playsInline), true); + assert.equal(await video.getAttribute('controls'), ''); + assert.equal(await video.evaluate(element => element.textTracks[0].mode), 'hidden', + 'Compact previews do not duplicate the centered instruction card'); + assert.equal(await video.evaluate(element => element.textTracks[0].cues[0].line), 0, + 'Optional captions stay above the centered instruction card'); + if (first) { + await video.evaluate(element => { element.textTracks[0].mode = 'showing'; }); + assert.equal(await video.evaluate(element => element.textTracks[0].mode), 'showing'); + await video.evaluate(element => { element.textTracks[0].mode = 'hidden'; }); + } + await fits(popup); + await popup.hover(); + assert.equal(await popup.isVisible(), true, 'The pointer can enter the preview and use its controls'); + await page.mouse.move(0, 0); + await popup.waitFor({ state: 'hidden' }); + await stopped(video); + + await target.focus(); + await playing(video); + await target.press('Tab'); + assert.equal(await popup.getByRole('button', { name: 'Close video guide' }) + .evaluate(element => element === document.activeElement), true, 'Keyboard users can enter the popup'); + await page.keyboard.press('Shift+Tab'); + assert.equal(await target.evaluate(element => element === document.activeElement), true, + 'Reverse tab returns from the popup to its icon'); + await target.press('Tab'); + await page.keyboard.press('Escape'); + await popup.waitFor({ state: 'hidden' }); + assert.equal(await target.evaluate(element => element === document.activeElement), true); + + await target.click(); + await playing(video); + await page.mouse.move(0, 0); + assert.equal(await popup.isVisible(), true, 'Click keeps the preview open'); + assert.ok(await popup.locator('.help-demo-transcript li').count() > 0); + assert.ok((await popup.locator('a[download]').getAttribute('download')).endsWith(`${id}.webm`)); + if (first && output) await page.screenshot({ path: path.join(output, 'left-video-guides.png'), fullPage: true }); + await popup.getByRole('button', { name: 'Close video guide' }).click(); + await stopped(video); + assert.deepEqual(await fieldValues(page), initial, `${id}: video help does not change form values`); + assert.equal(await page.locator('dialog[open]').count(), 0, 'Video buttons do not trigger form dialogs'); + first = false; + } + const target = panel.locator('[data-help-video]').first(); + await target.click(); + const popup = await popupFor(target), video = popup.locator('video'); + await section.locator(':scope > summary').click(); + await popup.waitFor({ state: 'hidden' }); + await stopped(video); + } + + const input = page.getByRole('complementary', { name: 'Sequence and structure input', exact: true }); + await input.locator('details.control-group > summary').click(); + const target = input.locator('[data-help-video="choose-example"]'); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await target.hover(); + const popup = await popupFor(target), video = popup.locator('video'); + await popup.waitFor({ state: 'visible' }); + assert.equal(await video.evaluate(element => element.paused), true, 'Reduced motion suppresses automatic playback'); + await target.click(); + await playing(video); + await popup.getByRole('button', { name: 'Close video guide' }).click(); + await touchVideo(page, output); + } finally { + page.off('request', record); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.mouse.move(0, 0); + await page.locator('#sequence').focus(); + } +} diff --git a/scripts/browser-pages.js b/scripts/browser-pages.js index ca8a7e9..582a755 100644 --- a/scripts/browser-pages.js +++ b/scripts/browser-pages.js @@ -4,11 +4,12 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { chromium } from 'playwright'; +import { exerciseDocsHelpVideos } from './browser-help-videos.js'; const root = path.resolve(import.meta.dirname, '..'); const output = path.join(root, 'output/playwright/pages'); const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', - '.svg': 'image/svg+xml', '.png': 'image/png' }; + '.svg': 'image/svg+xml', '.png': 'image/png', '.webm': 'video/webm', '.vtt': 'text/vtt' }; async function serve() { const server = http.createServer((request, response) => { @@ -35,7 +36,7 @@ async function main() { const origin = `http://127.0.0.1:${server.address().port}`; let browser, page; let phase = 'browser startup'; - const errors = [], modules = []; + const errors = [], modules = [], mediaRequests = []; try { browser = await chromium.launch({ headless: true, ...(process.env.VARRI_BROWSER_PATH ? { executablePath: process.env.VARRI_BROWSER_PATH } : {}) }); @@ -45,6 +46,7 @@ async function main() { if (response.url().startsWith(origin) && response.status() >= 400) errors.push(response.url()); }); page.on('request', request => { if (request.resourceType() === 'script') modules.push(request.url()); }); + page.on('request', request => { if (/\.(?:webm|vtt)(?:\?|$)/.test(request.url())) mediaRequests.push(request.url()); }); page.on('requestfailed', request => errors.push(`${request.url()}: ${request.failure()?.errorText}`)); await page.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 200, body: '' })); @@ -60,10 +62,35 @@ async function main() { const brokenAnchors = await page.evaluate(() => [...document.querySelectorAll('#markdown-text a[href^="#"]')] .map(link => link.getAttribute('href')).filter(href => href.length > 1 && !document.getElementById(decodeURIComponent(href.slice(1))))); assert.deepEqual(brokenAnchors, [], 'Composed help links must target existing sections'); + await page.waitForFunction(() => document.querySelectorAll('[data-help-demo-ready]').length >= 12); + assert.equal(mediaRequests.filter(url => /\.webm(?:\?|$)/.test(url)).length, 0, + 'Composed help must not request WebM before explicit expansion'); + const fastaDemo = page.locator('[data-help-demo="import-fasta"]'); + const trigger = fastaDemo.getByRole('button', { name: /Watch .* demo/ }); + // A slow poster response must not turn a correct lazy player into a flaky test. + await page.route(origin + '/doc/demos/import-fasta.png*', async route => { + await new Promise(resolve => setTimeout(resolve, 150)); + await route.continue(); + }); + await trigger.focus(); + await fastaDemo.locator('.help-demo-teaser').waitFor({ state: 'visible' }); + await page.waitForFunction(() => { + const image = document.querySelector('[data-help-demo="import-fasta"] .help-demo-teaser img'); + return image?.complete && image.naturalWidth > 0; + }); + await trigger.click(); + const video = fastaDemo.locator('video'); + assert.equal(await video.isVisible(), true, 'Explicit activation expands the player'); + assert.equal(await video.getAttribute('autoplay'), null); + assert.equal(await video.getAttribute('loop'), null); + assert.equal(await video.locator('track[kind="captions"]').count(), 1); + assert.equal(await fastaDemo.locator('.help-demo-transcript li').count() > 1, true); + await exerciseDocsHelpVideos(page); await page.locator('#markdown-text a[href="#region-highlights"]').first().click(); assert.equal(new URL(page.url()).hash, '#region-highlights'); - assert.equal(await page.locator('#markdown-text img').evaluateAll(images => - images.every(image => image.complete && image.naturalWidth > 0)), true, 'Help images resolve after guide extraction'); + assert.equal(await page.locator('#markdown-text img').evaluateAll(images => images + .filter(image => !image.closest('.help-demo-teaser')) + .every(image => image.complete && image.naturalWidth > 0)), true, 'Help images resolve after guide extraction'); await page.screenshot({ path: path.join(output, 'readme.png') }); phase = 'citation tabs and downloads'; @@ -98,7 +125,7 @@ async function main() { assert.ok(modules.every(url => url.startsWith(origin + '/src/')), 'Page scripts are native source modules'); assert.deepEqual(errors, [], 'Ancillary page browser errors'); await page.screenshot({ path: path.join(output, 'citation.png') }); - console.log(`Native pages passed (Chromium ${browser.version()}): complete help, anchors, images, citation tabs and five downloads.`); + console.log(`Native pages passed (Chromium ${browser.version()}): complete help, lazy captioned demos, anchors, images, citation tabs and five downloads.`); } catch (error) { if (page) { await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: true }).catch(() => {}); diff --git a/scripts/browser-sequence-names.js b/scripts/browser-sequence-names.js index 6e61524..eb3f967 100644 --- a/scripts/browser-sequence-names.js +++ b/scripts/browser-sequence-names.js @@ -22,7 +22,7 @@ export async function exerciseSequenceNames(page, origin, output) { await page.goto(`${origin}/index.html?${params}`, { waitUntil: 'networkidle' }); await ready(); assert.deepEqual(await canvas.locator('[data-varri-text] text').allTextContents(), ['RNA α', 'Target β']); - const panels = await page.locator('.controls-column details > summary').allTextContents(); + const panels = await page.locator('.controls-column details.control-group > summary').allTextContents(); const settingsIndex = panels.findIndex(text => text.includes('Visualization Settings')); assert.ok(settingsIndex >= 0); assert.ok(panels[settingsIndex + 1].includes('Text Annotations'), 'Text annotations immediately follow settings'); diff --git a/scripts/browser-text-ui.js b/scripts/browser-text-ui.js index 825359d..7830529 100644 --- a/scripts/browser-text-ui.js +++ b/scripts/browser-text-ui.js @@ -10,7 +10,7 @@ export async function exerciseTextAnnotationUI(page, origin, output) { document.getElementById('rendering-canvas').style.visibility !== 'hidden'); const open = async () => { const panel = page.locator('#textAnnotationSubmitBtn').locator('xpath=ancestor::details[1]'); - if (!await panel.evaluate(element => element.open)) await panel.locator('summary').click(); + if (!await panel.evaluate(element => element.open)) await panel.locator(':scope > summary').click(); }; await page.goto(`${origin}/index.html?${params}`, { waitUntil: 'networkidle' }); await ready(); diff --git a/scripts/browser-ui.js b/scripts/browser-ui.js index 4742cd6..4b148b0 100644 --- a/scripts/browser-ui.js +++ b/scripts/browser-ui.js @@ -9,10 +9,12 @@ import { checkPNGDialog, checkPNGResize } from './browser-png-export.js'; import { exercisePositionControlsUI } from './browser-position-ui.js'; import { exerciseTextAnnotationUI } from './browser-text-ui.js'; import { exerciseSequenceNames } from './browser-sequence-names.js'; +import { exerciseLeftVideoGuides } from './browser-left-video-guides.js'; import { checkInputHighlights } from './browser-input-highlights.js'; import { checkCrossingRriExample } from './browser-crossing-rri.js'; import { checkJSONFiles } from './browser-json-files.js'; import { checkExampleDropdownOverlay } from './browser-example-dropdown.js'; +import { exerciseActionVideoPreviews } from './browser-help-videos.js'; const root = path.resolve(import.meta.dirname, '..'); const catalog = await import(pathToFileURL(path.join(root, 'example-data.js')).href); const examples = catalog.default || catalog; @@ -20,7 +22,8 @@ const browserType = process.argv.includes('--firefox') ? firefox : chromium; const outputName = browserType === firefox ? 'ui-firefox' : 'ui'; const output = path.join(root, 'output/playwright', outputName + (process.env.VARRI_BROWSER_CHANNEL ? '-channel' : '')); const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', - '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json' }; + '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', + '.webm': 'video/webm', '.vtt': 'text/vtt' }; async function serve() { const server = http.createServer((request, response) => { @@ -80,7 +83,13 @@ async function main() { page.on('response', response => { if (response.url().startsWith(origin) && response.status() >= 400) errors.push(response.url()); }); - page.on('requestfailed', request => errors.push(`${request.url()}: ${request.failure()?.errorText}`)); + page.on('requestfailed', request => { + const failure = request.failure()?.errorText; + // Browsers cancel media range reads on seek or navigation. Playback and + // caption loading are asserted separately; HTTP/decode failures still fail. + if (request.resourceType() === 'media' && /^(?:NS_BINDING_ABORTED|net::ERR_ABORTED)$/.test(failure)) return; + errors.push(`${request.url()}: ${failure}`); + }); // Prove the source viewer does not rely on a previous build or npm install. await page.route('**/*', route => { const url = new URL(route.request().url()); @@ -94,6 +103,15 @@ async function main() { phase = 'initial viewer load'; await page.goto(origin + '/index.html', { waitUntil: 'networkidle' }); await ready(page); + phase = 'left-panel video guides'; + await exerciseLeftVideoGuides(page, output); + phase = 'right-panel video help previews'; + await exerciseActionVideoPreviews(page, output); + if (process.argv.includes('--videos-only')) { + assert.deepEqual(errors, [], 'Browser errors during action video previews'); + console.log(`Video helpers passed (${browserType.name()} ${browser.version()}): action previews and left-panel guides.`); + return; + } phase = 'example dropdown overlay'; await checkExampleDropdownOverlay(page); if (process.argv.includes('--positions-only')) { diff --git a/scripts/check-package.js b/scripts/check-package.js index 1641b59..2124b6e 100644 --- a/scripts/check-package.js +++ b/scripts/check-package.js @@ -1,11 +1,13 @@ // Exercise the tarball, not the source checkout: missing files must fail CI. import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createRequire } from 'node:module'; import { JSDOM } from 'jsdom'; +import { scenes } from '../recording/scenes/index.js'; const root = path.resolve(import.meta.dirname, '..'); const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'varri-package-check-')); @@ -91,6 +93,21 @@ try { 'licenses/Fornac-Apache-2.0.txt', 'licenses/Fornac-NOTICE.md']) { assert.ok(fs.statSync(path.join(installed, 'dist', name)).size > 0, `Missing embedding asset: ${name}`); } + const demoDirectory = path.join(installed, 'doc', 'demos'); + const demoManifest = JSON.parse(fs.readFileSync(path.join(demoDirectory, 'manifest.json'), 'utf8')); + assert.equal(demoManifest.schemaVersion, 1); + assert.deepEqual(demoManifest.clips.map(clip => clip.id).sort(), scenes.map(scene => scene.id).sort(), + 'All registered help demonstrations are packaged exactly once'); + for (const clip of demoManifest.clips) { + assert.ok(clip.transcript.length > 0 && clip.transcript.every(step => typeof step === 'string' && step.trim()), + `${clip.id}: packaged transcript`); + for (const asset of Object.values(clip.assets)) { + const content = fs.readFileSync(path.join(demoDirectory, asset.file)); + assert.equal(content.length, asset.bytes, `${clip.id}: packaged ${asset.file} size`); + assert.equal(createHash('sha256').update(content).digest('hex'), asset.sha256, + `${clip.id}: packaged ${asset.file} checksum`); + } + } // Markdown embeds are loaded by README.html after parsing, so inspect them too. for (const name of helpDocuments) { const markdown = fs.readFileSync(path.join(installed, name), 'utf8'); diff --git a/src/ui/bootstrap.js b/src/ui/bootstrap.js index 8d80e64..687e264 100644 --- a/src/ui/bootstrap.js +++ b/src/ui/bootstrap.js @@ -17,6 +17,7 @@ import { createJSONFileActions } from './json-file-actions.js'; import { createDialogActions } from './dialog-actions.js'; import { createFieldActions } from './field-actions.js'; import { createInputHighlights } from './input-highlights.js'; +import { enhanceHelpVideos } from './help-videos.js'; /** Mount a complete viewer. Only field/annotation DTOs enter Vue's store. */ export function createViewerApp(options = {}) { @@ -37,6 +38,8 @@ export function createViewerApp(options = {}) { const app = createApp(App); app.provide(VIEWER_CONTEXT, { state, actions, examples, colors }); app.mount(root); + const recordingDemo = new URLSearchParams(document.defaultView.location.search).has('demoRecording'); + const disposeHelpVideos = recordingDemo ? () => {} : enhanceHelpVideos(root); actions.observeBackdrops(); const outsideClick = event => { if (state.exampleOpen && !document.getElementById('exampleDropdown')?.contains(event.target)) state.exampleOpen = false; @@ -62,6 +65,7 @@ export function createViewerApp(options = {}) { actions.disposeJSONFiles(); actions.cancelPNGExport(); actions.cancelRendering(); actions.disposeBackdrops(); actions.disposeDialogs(); actions.disposeNavigation(); actions.disposeTextAnnotations(); + disposeHelpVideos(); document.removeEventListener('click', outsideClick); app.unmount(); }, diff --git a/src/ui/components/annotation-panel.js b/src/ui/components/annotation-panel.js index 5833fc4..932d700 100644 --- a/src/ui/components/annotation-panel.js +++ b/src/ui/components/annotation-panel.js @@ -1,19 +1,22 @@ import { computed, inject } from 'vue'; import { VIEWER_CONTEXT } from './context.js'; +import VideoHelp from './video-help.js'; const CONFIG = { subsequences: { title: 'Subsequence Highlights', aria: 'Subsequence highlights', list: 'highlight-list', counter: 'subseqCounterUI', edit: 'subseqEditId', button: 'highlightSubmitBtn', - itemClass: '', empty: 'No highlights defined.', itemName: 'highlight' }, + itemClass: '', empty: 'No highlights defined.', itemName: 'highlight', video: 'add-subsequence-highlight' }, regions: { title: 'Region Highlights', aria: 'Region highlights', list: 'region-list', counter: 'regionCounterUI', edit: 'regionEditId', button: 'regionSubmitBtn', - itemClass: 'region-item', empty: 'No region highlights defined.', itemName: 'region highlight' }, + itemClass: 'region-item', empty: 'No region highlights defined.', itemName: 'region highlight', + video: 'add-region-highlight' }, mutations: { title: 'Point Mutations', aria: 'Point mutations', list: 'mutation-list', counter: 'mutationCounterUI', edit: 'mutationEditId', button: 'mutationSubmitBtn', - itemClass: 'mutation-item', empty: 'No mutations defined.', itemName: 'mutation' }, + itemClass: 'mutation-item', empty: 'No mutations defined.', itemName: 'mutation', video: 'add-point-mutation' }, }; export default { + components: { VideoHelp }, name: 'AnnotationPanel', props: { kind: { type: String, required: true } }, setup(props) { const context = inject(VIEWER_CONTEXT); @@ -25,7 +28,8 @@ export default { {{ config.title }} {{ items.length ? '(' + items.length + ')' : '' }}
- +
+
@@ -45,7 +49,8 @@ export default { -
+
+
`, diff --git a/src/ui/components/canvas-help-dialog.js b/src/ui/components/canvas-help-dialog.js index e873987..955c43f 100644 --- a/src/ui/components/canvas-help-dialog.js +++ b/src/ui/components/canvas-help-dialog.js @@ -13,18 +13,24 @@ export default viewerComponent('canvas-help-dialog', `
Select
Ctrl/Command-click a nucleotide, numbering label, or text label to toggle selection. Ctrl/Command-drag the background to select all elements fully inside the rectangle. - A plain click on an element, Ctrl/Command-click on the background, or an empty selection rectangle clears selection.
+ A plain click on an element, Ctrl/Command-click on the background, or an empty selection rectangle clears selection. +
Move
Drag an element to move it. Drag a selected element to move the whole selection. - Dragging an unselected element clears the selection. Moved nucleotides and numbering labels stay fixed, even with Force layout enabled.
+ Dragging an unselected element clears the selection. Moved nucleotides and numbering labels stay fixed, even with Force layout enabled. +
Rotate
Select at least two elements, then Ctrl/Command-scroll to rotate them around the mouse pointer. - Text stays upright. Ordinary scrolling zooms, and dragging the background pans.
+ Text stays upright. Ordinary scrolling zooms, and dragging the background pans. +
Moved: X
The count 'X' includes manually positioned nucleotides and numbering labels. Click it to select them all.
-
Release
Unfix selected nodes at their current positions. They resume moving when Force layout is enabled. - Released nodes leave the Moved count. Text labels are unaffected.
+
Release
With Force layout enabled, unfix selected nodes at their current positions so they can move again. Release is disabled in static layouts. + Released nodes leave the Moved count. Text labels are unaffected. +
Reset
Restore selected manually positioned nodes to their positions and fixation before their first edit. - Text labels are unaffected. To reset a released node, undo its release first.
+ Text labels are unaffected. To reset a released node, undo its release first. +
Undo
Reverse the last drag, rotation, release, or reset, including text movements. - Ctrl/Command+Z also works while the canvas is focused. Up to 100 edits are kept for the current rendering.
+ Ctrl/Command+Z also works while the canvas is focused. Up to 100 edits are kept for the current rendering. + The Reset demo above also shows Undo reversing the reset.

Rerendering clears temporary node positions and undo history. SVG and PNG exports preserve the layout without selection outlines. Share links preserve text-label positions only.

diff --git a/src/ui/components/profile-panel.js b/src/ui/components/profile-panel.js index 3fe3f04..b7d70af 100644 --- a/src/ui/components/profile-panel.js +++ b/src/ui/components/profile-panel.js @@ -1,4 +1,5 @@ import { viewerComponent } from './context.js'; +import VideoHelp from './video-help.js'; export default viewerComponent('profile-panel', `
- `); + `, { VideoHelp }); diff --git a/src/ui/components/sequence-input.js b/src/ui/components/sequence-input.js index a0a0080..19c17eb 100644 --- a/src/ui/components/sequence-input.js +++ b/src/ui/components/sequence-input.js @@ -1,19 +1,24 @@ import { viewerComponent } from './context.js'; +import VideoHelp from './video-help.js'; export default viewerComponent('sequence-input', ``); + `, { VideoHelp }); diff --git a/src/ui/components/text-annotation-panel.js b/src/ui/components/text-annotation-panel.js index c00cf00..89181a5 100644 --- a/src/ui/components/text-annotation-panel.js +++ b/src/ui/components/text-annotation-panel.js @@ -1,4 +1,5 @@ import { viewerComponent } from './context.js'; +import VideoHelp from './video-help.js'; export default viewerComponent('text-annotation-panel', ``); +`, { VideoHelp }); diff --git a/src/ui/components/video-help.js b/src/ui/components/video-help.js new file mode 100644 index 0000000..8a74a29 --- /dev/null +++ b/src/ui/components/video-help.js @@ -0,0 +1,10 @@ +export default { + name: 'VideoHelp', + props: { clip: { type: String, required: true }, label: { type: String, required: true } }, + template: ``, +}; diff --git a/src/ui/components/viewer-panel.js b/src/ui/components/viewer-panel.js index a27f666..c371044 100644 --- a/src/ui/components/viewer-panel.js +++ b/src/ui/components/viewer-panel.js @@ -34,16 +34,16 @@ export default viewerComponent('viewer-panel', `

Manual positions

- - - - -
@@ -53,8 +53,8 @@ export default viewerComponent('viewer-panel', `
- - + + Please diff --git a/src/ui/components/visualization-settings.js b/src/ui/components/visualization-settings.js index 260fde9..5a4c44b 100644 --- a/src/ui/components/visualization-settings.js +++ b/src/ui/components/visualization-settings.js @@ -1,4 +1,5 @@ import { viewerComponent } from './context.js'; +import VideoHelp from './video-help.js'; export default viewerComponent('visualization-settings', `