diff --git a/.claude/launch.json b/.claude/launch.json
index ac009f1..a695751 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -7,6 +7,13 @@
"runtimeArgs": ["start", "--prefix", "/Volumes/Samsung-Ext/dev/DeepPhe-Visualizer-v2"],
"port": 3000,
"autoPort": true
+ },
+ {
+ "name": "viz2-docs",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["--prefix", "docs-site", "run", "serve"],
+ "port": 3002,
+ "autoPort": true
}
]
}
diff --git a/README.md b/README.md
index 0be9e46..a457cc1 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,8 @@ npm run eject # Eject from react-scripts (one-way operation)
### User documentation
+The published user guide is at .
+
The task-oriented user guide is built with Docusaurus in an isolated
`docs-site/` package. Node.js 20 or later is required for documentation builds.
diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts
index a70a979..3819a2e 100644
--- a/docs-site/sidebars.ts
+++ b/docs-site/sidebars.ts
@@ -7,7 +7,13 @@ const sidebars: SidebarsConfig = {
type: 'category',
label: 'Getting Started',
collapsed: false,
- items: ['getting-started/overview', 'getting-started/first-cohort'],
+ items: [
+ 'getting-started/overview',
+ 'getting-started/first-cohort',
+ 'getting-started/guided-exercise',
+ 'getting-started/exercise-targeted-therapy',
+ 'getting-started/exercise-compare-outcomes',
+ ],
},
{
type: 'category',
diff --git a/docs/assets/screenshots/end-user/exercise-cancer-tumor-detail.png b/docs/assets/screenshots/end-user/exercise-cancer-tumor-detail.png
new file mode 100644
index 0000000..fa6503f
Binary files /dev/null and b/docs/assets/screenshots/end-user/exercise-cancer-tumor-detail.png differ
diff --git a/docs/assets/screenshots/end-user/exercise-conflicted-summary.png b/docs/assets/screenshots/end-user/exercise-conflicted-summary.png
new file mode 100644
index 0000000..35d8606
Binary files /dev/null and b/docs/assets/screenshots/end-user/exercise-conflicted-summary.png differ
diff --git a/docs/assets/screenshots/end-user/exercise-cross-filter-counts.png b/docs/assets/screenshots/end-user/exercise-cross-filter-counts.png
new file mode 100644
index 0000000..6cd1e8d
Binary files /dev/null and b/docs/assets/screenshots/end-user/exercise-cross-filter-counts.png differ
diff --git a/docs/assets/screenshots/end-user/exercise-negated-concepts.png b/docs/assets/screenshots/end-user/exercise-negated-concepts.png
new file mode 100644
index 0000000..03e394a
Binary files /dev/null and b/docs/assets/screenshots/end-user/exercise-negated-concepts.png differ
diff --git a/docs/assets/screenshots/end-user/exercise-relapse-timeline.png b/docs/assets/screenshots/end-user/exercise-relapse-timeline.png
new file mode 100644
index 0000000..40e35f3
Binary files /dev/null and b/docs/assets/screenshots/end-user/exercise-relapse-timeline.png differ
diff --git a/docs/assets/screenshots/end-user/exercise-source-pathology.png b/docs/assets/screenshots/end-user/exercise-source-pathology.png
new file mode 100644
index 0000000..e137b8e
Binary files /dev/null and b/docs/assets/screenshots/end-user/exercise-source-pathology.png differ
diff --git a/docs/assets/screenshots/end-user/exercise-stage-filter-selected.png b/docs/assets/screenshots/end-user/exercise-stage-filter-selected.png
new file mode 100644
index 0000000..3d9bd5f
Binary files /dev/null and b/docs/assets/screenshots/end-user/exercise-stage-filter-selected.png differ
diff --git a/docs/assets/screenshots/end-user/outcome-progressors-behavior.png b/docs/assets/screenshots/end-user/outcome-progressors-behavior.png
new file mode 100644
index 0000000..6eeecb1
Binary files /dev/null and b/docs/assets/screenshots/end-user/outcome-progressors-behavior.png differ
diff --git a/docs/assets/screenshots/end-user/outcome-progressors-stage.png b/docs/assets/screenshots/end-user/outcome-progressors-stage.png
new file mode 100644
index 0000000..bcccbee
Binary files /dev/null and b/docs/assets/screenshots/end-user/outcome-progressors-stage.png differ
diff --git a/docs/assets/screenshots/end-user/outcome-responders-behavior.png b/docs/assets/screenshots/end-user/outcome-responders-behavior.png
new file mode 100644
index 0000000..80f5583
Binary files /dev/null and b/docs/assets/screenshots/end-user/outcome-responders-behavior.png differ
diff --git a/docs/assets/screenshots/end-user/outcome-responders-stage.png b/docs/assets/screenshots/end-user/outcome-responders-stage.png
new file mode 100644
index 0000000..a48c2bb
Binary files /dev/null and b/docs/assets/screenshots/end-user/outcome-responders-stage.png differ
diff --git a/docs/assets/screenshots/end-user/therapy-cohort-table.png b/docs/assets/screenshots/end-user/therapy-cohort-table.png
new file mode 100644
index 0000000..0fb3b86
Binary files /dev/null and b/docs/assets/screenshots/end-user/therapy-cohort-table.png differ
diff --git a/docs/assets/screenshots/end-user/therapy-gene-repaint.png b/docs/assets/screenshots/end-user/therapy-gene-repaint.png
new file mode 100644
index 0000000..e947409
Binary files /dev/null and b/docs/assets/screenshots/end-user/therapy-gene-repaint.png differ
diff --git a/docs/assets/screenshots/end-user/therapy-her2-gap.png b/docs/assets/screenshots/end-user/therapy-her2-gap.png
new file mode 100644
index 0000000..473f35d
Binary files /dev/null and b/docs/assets/screenshots/end-user/therapy-her2-gap.png differ
diff --git a/docs/assets/screenshots/end-user/therapy-treatment-search.png b/docs/assets/screenshots/end-user/therapy-treatment-search.png
new file mode 100644
index 0000000..277ec02
Binary files /dev/null and b/docs/assets/screenshots/end-user/therapy-treatment-search.png differ
diff --git a/docs/contributors/screenshot-capture.md b/docs/contributors/screenshot-capture.md
index 471b3fa..a3565c4 100644
--- a/docs/contributors/screenshot-capture.md
+++ b/docs/contributors/screenshot-capture.md
@@ -31,9 +31,29 @@ Useful environment variables:
- `VIZ2_SCREENSHOT_DIR` — where source captures are written (default `../Viz2_screenshots`).
- `DOC_PATIENT_ID` — the synthetic patient used for the standalone-patient and Document Viewer captures (default `fake_patient3`).
- `COLLAPSED_DATE_PATIENT_ID` — a synthetic patient whose notes collapse to one date, used for the timeline's episode-dropdown fallback capture (optional).
+- `CAPTURE_ONLY` — a comma-separated list of file-name fragments. When set, only matching captures are written, so one data-dependent series can be re-taken without re-shooting the whole set. See [Re-take a single series](#re-take-a-single-series).
The capture script forces the **Standard** theme so images are consistent, disables animations, and waits for content to load before each shot.
+### Guided-exercise captures
+
+The [guided exercise](../getting-started/guided-exercise.md) needs specific records: one patient whose structure and source notes agree, and one carrying contradictory extractions. Those captures are all optional, and each has an override:
+
+- `EXERCISE_CORROBORATED_PATIENT_ID` — the patient with a metastatic second cancer confirmed by a later pathology report (default `fake_patient125`).
+- `EXERCISE_CONFLICTED_PATIENT_ID` — the patient whose TNM value disagrees with their stage, and whose radiology report both asserts and negates metastatic disease (default `fake_patient460`).
+- `EXERCISE_STAGE_VALUE` — the Stage card value the exercise filters on (default `Stage IV`).
+- `EXERCISE_AGE_BAND` — the Age at Dx band that, combined with the stage value, narrows the cohort far enough for the conflicted patient to render as a clickable [patient dot](../cohort-explorer/patient-dots.md) (default `30-39`).
+
+If you change any of these, re-read the exercise page: it quotes cohort counts and findings that come from these specific records.
+
+### Re-take a single series
+
+```bash
+CAPTURE_ONLY=exercise npm run capture:screenshots
+```
+
+Captures whose file name contains the fragment are written; every other tracked image is left untouched. Useful when one series is flaky or data-dependent and a full re-run would churn — or degrade — images that are already good.
+
## Required vs. optional captures
`scripts/capture-screenshots.mjs` splits its targets into two sets:
diff --git a/docs/getting-started/exercise-compare-outcomes.md b/docs/getting-started/exercise-compare-outcomes.md
new file mode 100644
index 0000000..c3bc2d8
--- /dev/null
+++ b/docs/getting-started/exercise-compare-outcomes.md
@@ -0,0 +1,126 @@
+---
+title: "Guided exercise: compare treatment outcomes"
+sidebar_label: "Exercise: compare outcomes"
+description: A worked cohort-comparison exercise that contrasts complete responders with progressors and reads the difference off the filter cards — including a whole stage value going dark.
+---
+
+# Guided exercise: compare treatment outcomes
+
+Comparing subgroups is a core reason researchers reach for a tool like this. The [targeted-therapy exercise](exercise-targeted-therapy.md) built one cohort; this one builds two and holds them up against each other:
+
+> **Some tumors responded completely to treatment. Others kept growing. What separates the two groups?**
+
+You will define each group in turn and read the difference directly off the cards — no export, no statistics package, just the repaint. Allow about 10 minutes.
+
+## Before you start
+
+Written against the bundled **synthetic demonstration dataset** of 500 breast cancer patients. Counts below come from that dataset; they will differ on other data but the method is the same — see [Adapt this to your own data](#adapt-this-to-your-own-data).
+
+:::caution Synthetic data
+
+These records are generated. Study how the Visualizer **represents** the groups, not the specific fabricated values.
+
+:::
+
+Start unfiltered — **Reset filters** so the toolbar reads **All 500 patients**.
+
+---
+
+## Step 1 — Define the responders
+
+Scroll to the **Clinical Course of Disease** card and select **`Pathologic Complete Response`** — patients whose tumor was gone at surgery after treatment, the best outcome on offer.
+
+The cohort settles at **33 patients**. Open the **Stage** card's Details dialog.
+
+
+
+**What you see.** Stage I `5/196`, Stage II `12/121`, Stage III `9/48` — and **Stage IV dimmed at `0/34`**. The Stage IV row is greyed and cannot be selected.
+
+**Why this matters.** That dimmed row is the Visualizer telling you something with an absence. A value goes disabled when **no patient in the current cohort carries it** — selecting it could only ever produce zero, so the interface takes it off the table (see [Values that can't add anyone are disabled](../cohort-explorer/selecting-filters.md#values-that-cant-add-anyone-are-disabled)). Here it means the clinical headline outright: **not one complete responder was stage IV**. You did not have to run a query to learn that — a whole stage simply went dark.
+
+---
+
+## Step 2 — Read the rest of the responders' profile
+
+Without changing the selection, look at the **Metastatic Behavior** card.
+
+
+
+**What you see.** `Metastatic` reads **1/30** — a single responder shows metastatic behavior. The Grade card, meanwhile, is spread evenly across `G1` (6), `G2` (8), and `G3` (8).
+
+**Why this matters.** The picture is internally consistent: the group that responded completely is early-stage and almost never metastatic. Note what you are *not* doing — you are not cross-tabulating outcome against stage, then outcome against metastasis, then outcome against grade as three separate queries. You defined the group once and every card now describes it at a glance. That is the cohort-comparison workflow: characterize a group by reading its repaint, not by asking one question at a time.
+
+---
+
+## Step 3 — Define the progressors
+
+**Reset filters**, then, on the same **Clinical Course of Disease** card, select **`Progressive Disease`** — patients whose disease advanced despite treatment.
+
+This cohort settles at **29 patients**. Open the **Stage** dialog again.
+
+
+
+**What you see.** A different shape entirely. **Stage IV is present and solid at `11/34`** — the largest single stage in this group. This time it is **Stage III** that is dimmed at `0/48`.
+
+**Why this matters.** Set this dialog beside the one from Step 1 and the contrast is the whole exercise. For responders, stage IV was the impossible value; for progressors, it is the dominant one. The same disabled-value mechanism now points the opposite way, and eleven of twenty-nine progressors carry the stage that not one responder did. The interface has drawn the line between good and bad outcomes for you, in the position of a single dimmed bar.
+
+---
+
+## Step 4 — Confirm the pattern holds
+
+Look at the progressors' **Metastatic Behavior** card.
+
+
+
+**What you see.** `Metastatic` reads **10/30**, against the responders' `1/30`. And the Grade card has shifted toward the high end — `G3` (12) now outweighs `G1` (3).
+
+**Why this matters.** Every card tells the same story the stage dialog did: the group that progressed is later-stage, far more often metastatic, and higher-grade. When independent variables all move together like this, you are looking at a real signal in the cohort rather than an artifact of one facet. Reading three cards took you seconds, and you never left the screen.
+
+:::note Counts on the bar vs. the cohort
+
+The `Pathologic Complete Response` bar reads **36** but the cohort settled at **33**; `Progressive Disease` reads **30** but gives **29**. The number on a facet bar counts extracted *mentions* of the concept, and one patient can carry a concept in more than one place. The drawer and the toolbar count **distinct patients**. When you cite a cohort size, use the toolbar's count. See [Understand cohort results](../cohort-explorer/understanding-results.md).
+
+:::
+
+---
+
+## What this exercise demonstrated
+
+| Step | Capability | The reason it matters |
+| --- | --- | --- |
+| 1 | A disabled value marks an empty intersection | A whole stage going dark states a finding without a query |
+| 2 | One selection characterizes a group across every card | Compare subgroups by reading the repaint, not one cross-tab at a time |
+| 3 | The disabled value points the other way for the other group | The contrast between two cohorts is legible in a single dimmed bar |
+| 4 | Independent facets move together | Concordant shifts across cards signal a real pattern, not a facet artifact |
+| 4 | Bar count (mentions) ≠ cohort count (patients) | Cite the toolbar's distinct-patient count when you report a cohort size |
+
+---
+
+## Adapt this to your own data
+
+The comparison method transfers to any pair of contrasting groups:
+
+1. **Pick two opposing values** of the same clinical axis — responders vs. progressors, recurrent vs. disease-free, one biomarker status vs. another.
+2. **Select the first, read three or four cards, then reset and select the second.** Keep the same cards in view both times so the differences are easy to spot.
+3. **Watch for disabled values.** A value that is available for one group and dimmed for the other is a difference the interface has already found for you.
+4. **Trust concordance, distrust a lone signal.** When stage, grade, and metastatic behavior all shift the same way, the pattern is robust; a difference on a single facet may be an extraction artifact. Confirm anything decision-relevant against the source records.
+
+## Presenting this as a demonstration
+
+The exercise compresses to about four minutes:
+
+| Time | Section |
+| --- | --- |
+| 0:00–0:30 | Premise and the synthetic-data caveat |
+| 0:30–1:45 | Step 1–2 — responders: Stage IV dimmed, metastatic 1/30 |
+| 1:45–3:15 | Step 3–4 — progressors: Stage IV 11/34, metastatic 10/30 |
+| 3:15–4:00 | Put the two Stage dialogs side by side and close on the contrast |
+
+The single strongest beat is opening the two Stage dialogs back to back — the dimmed bar jumps from Stage IV to Stage III between them. Keep **Reset filters** in reach for the switch.
+
+## Next steps
+
+- [Guided exercise: build a targeted-therapy cohort](exercise-targeted-therapy.md) — the companion cohort-building exercise
+- [Select and combine filters](../cohort-explorer/selecting-filters.md) — how and why values become disabled
+- [Understand cohort results](../cohort-explorer/understanding-results.md) — counts, mentions vs. patients, and empty results
+- [Filter Details dialog](../cohort-explorer/filter-details.md) — the full value list behind each card
diff --git a/docs/getting-started/exercise-targeted-therapy.md b/docs/getting-started/exercise-targeted-therapy.md
new file mode 100644
index 0000000..6f90bbf
--- /dev/null
+++ b/docs/getting-started/exercise-targeted-therapy.md
@@ -0,0 +1,117 @@
+---
+title: "Guided exercise: build a targeted-therapy cohort"
+sidebar_label: "Exercise: targeted therapy"
+description: A worked exercise that assembles a HER2-drug cohort through the search dialog, checks its biomarkers, and exports it — while showing why a missing value is not a negative result.
+---
+
+# Guided exercise: build a targeted-therapy cohort
+
+The [first guided exercise](guided-exercise.md) followed a question down to individual patients. This one stays at the cohort level and answers a different kind of question — the kind a researcher asks when assembling a study group:
+
+> **Pull everyone who received Herceptin — and check whether the record proves they should have.**
+
+Trastuzumab (Herceptin) is a HER2-targeted antibody. A patient only receives it if their tumor is HER2-positive, so the treatment is, in effect, a claim about a biomarker. This exercise builds that cohort, tests the claim against the extracted data, and exports the result. Allow about 10 minutes.
+
+## Before you start
+
+This exercise is written against the bundled **synthetic demonstration dataset** of 500 breast cancer patients. Every count below comes from that dataset; against different data the numbers will differ but the workflow is identical — see [Adapt this to your own data](#adapt-this-to-your-own-data).
+
+:::caution Synthetic data
+
+These records are generated and are not always internally consistent. What is worth studying is how the Visualizer **represents** what was extracted, not the specific fabricated values.
+
+:::
+
+Start at the Cohort Explorer in its unfiltered state — **Reset filters** if needed, so the toolbar reads **All 500 patients**.
+
+---
+
+## Step 1 — Find a value in a facet you cannot scroll
+
+In the **Treatment & Interventions** section, find the **Treatments** card and open its **Details** dialog.
+
+The Treatments facet holds hundreds of distinct values — regimens, agents, procedures. Scrolling to a specific drug is hopeless. Type `Trastuzumab` into the dialog's search box.
+
+
+
+**What you see.** The search narrows the list to `Trastuzumab` (71) and the combination regimens that contain it — `Docetaxel/Pertuzumab/Trastuzumab Regimen`, `Carboplatin/Paclitaxel/Trastuzumab`, and others, each at 1.
+
+**Why this matters.** Long facets are the norm, not the exception, once you leave demographics behind. The search box — with the sort control beside it — is how you work a facet with hundreds of values instead of guessing whether a drug is present. Notice too that the drug appears both on its own and inside regimen names: the Visualizer keeps the specific regimen a patient received rather than flattening everything to a generic "chemotherapy," which is what lets you target one agent.
+
+Select the standalone **`Trastuzumab`** row (not a combination regimen) and close the dialog.
+
+---
+
+## Step 2 — Read the biomarker the drug implies
+
+The cohort is now **71 patients**. Every one of them received a HER2-targeted antibody, which should mean every one of them has HER2-positive disease. Check that against the extracted molecular data.
+
+Open the **Genes** card's Details dialog and search for `ERBB` (the gene that encodes the HER2 receptor).
+
+
+
+**What you see.** `ERBB 2 Gene` reads **61/403** — of the 403 patients in the whole dataset with that gene extracted, **61 are inside this 71-patient cohort**.
+
+**Why this matters.** The treatment and the biomarker corroborate each other. You did not filter on HER2 — you filtered on a drug — and the HER2 gene concentrated in the result anyway. That is the cohort-level version of the provenance check from the first exercise: an independent signal in the data agrees with the clinical action, which is exactly what should happen and a reason to trust the group you have built.
+
+---
+
+## Step 3 — Notice what is *not* there
+
+Now look at the **HER2/Neu Status** card in the same **Molecular Markers & Biomarkers** section.
+
+
+
+**What you see.** `HER2 Status` reads **40/276** — a discrete HER2 status **finding** was extracted for only **40 of the 71** patients in the cohort.
+
+**Why this matters.** This is the payoff of the exercise, and it is easy to miss. Seventy-one patients were treated as HER2-positive. Sixty-one carry the HER2 gene. But a structured HER2 *status* result exists for only forty. The other thirty-one are not HER2-negative — the value is simply **absent** from the extracted record.
+
+Had you started the other way around — filtering on "HER2 status is present," or worse, treating a blank HER2 field as "negative" — you would have silently dropped a third of a genuine HER2-driven cohort. **A missing value is not a negative result.** In a medical dataset built by extraction, coverage is never complete, and the safest single habit is to reach for the more complete signal (here, the treatment) rather than the sparser one (the discrete status finding). See [Understand cohort results](../cohort-explorer/understanding-results.md) on why an absent value is not a confirmed negative.
+
+---
+
+## Step 4 — Review and export the cohort
+
+Expand the **Selected Patients** drawer to work the table.
+
+
+
+**What you see.** All 71 patients, one row each, with columns for demographics, stage, grade, diagnoses, **Biomarkers**, **Treatments**, procedures, and key findings. The Biomarkers column is full of `ERBB2 Gene` — the same signal from Step 2, now visible per patient.
+
+Three controls in the drawer toolbar finish the workflow:
+
+- **Sort** by clicking a column header — sort by **Document Count** to bring the richest records to the top, the natural place to start a manual review.
+- **Choose columns** with the column button, to surface or hide fields (the Biomarkers and Treatments columns are what make this cohort worth reading).
+- **Export to CSV** with the download button. The export contains the currently loaded, filtered, and sorted rows with the visible columns.
+
+**Why this matters.** A cohort you cannot get out of the tool is a dead end. The export is what carries this group into a statistics package, a manuscript table, or a chart review worklist — and because it respects your current sort and column choices, the file matches what you were looking at. See [The Selected Patients table](../cohort-explorer/patients-table.md) and [Export results](../cohort-explorer/exporting-results.md) for the details.
+
+---
+
+## What this exercise demonstrated
+
+| Step | Capability | The reason it matters |
+| --- | --- | --- |
+| 1 | Search a long facet in the Details dialog | Most clinical facets are too long to scroll; search is how you find a known value |
+| 1 | Specific regimens are preserved, not flattened | You can target one agent because the extraction kept it distinct |
+| 2 | An independent signal corroborates the filter | Filtering on a drug concentrated its biomarker — a cohort-level provenance check |
+| 3 | Coverage is partial (40 of 71 have a HER2 status) | A missing value is **not** a negative result; prefer the more complete signal |
+| 4 | Sort, choose columns, export to CSV | A cohort has to leave the tool to be useful, and the export matches your view |
+
+---
+
+## Adapt this to your own data
+
+The workflow transfers to any treatment that implies a biomarker or target:
+
+1. **Pick a targeted therapy** whose use encodes a molecular fact — an anti-HER2 antibody, an endocrine agent (implies hormone-receptor-positive disease), a PARP inhibitor (implies a BRCA context), and so on.
+2. **Corroborate** by opening the relevant gene or biomarker facet and confirming the expected concept concentrates in the cohort.
+3. **Measure the coverage gap.** Compare the treatment count with the count of the discrete biomarker finding. The difference is the set of patients for whom the value is absent rather than negative — the population most likely to be mishandled by a naive filter.
+4. **Export** and record which signal you built the cohort from, so a downstream reader knows whether a blank field means "negative" or "not extracted." It almost always means the latter.
+
+## Next steps
+
+- [Guided exercise: compare treatment outcomes](exercise-compare-outcomes.md) — the companion cohort-comparison exercise
+- [Select and combine filters](../cohort-explorer/selecting-filters.md) — AND/OR logic and disabled values
+- [Filter Details dialog](../cohort-explorer/filter-details.md) — searching and sorting a facet's values
+- [Export results](../cohort-explorer/exporting-results.md) — what the CSV contains and how it respects your view
diff --git a/docs/getting-started/first-cohort.md b/docs/getting-started/first-cohort.md
index 78cf7dd..7dc464e 100644
--- a/docs/getting-started/first-cohort.md
+++ b/docs/getting-started/first-cohort.md
@@ -44,3 +44,7 @@ Expand the Selected Patients drawer to inspect the current page. You can search
Use the CSV download button in the patient table. The export contains the currently loaded, filtered, and sorted rows and the columns that are visible.
For details, see [Export results](../cohort-explorer/exporting-results.md).
+
+## Next step
+
+Now that you can build a cohort, work through the [guided exercise](guided-exercise.md). It follows a single question end to end — from 500 patients down to two individual records — and explains what each finding along the way is telling you.
diff --git a/docs/getting-started/guided-exercise.md b/docs/getting-started/guided-exercise.md
new file mode 100644
index 0000000..1fc5fd2
--- /dev/null
+++ b/docs/getting-started/guided-exercise.md
@@ -0,0 +1,217 @@
+---
+title: "Guided exercise: find the patients who need review"
+sidebar_label: "Exercise: find who needs review"
+description: A worked end-to-end exercise that narrows a cohort, opens two contrasting patients, and explains what each finding tells you.
+---
+
+# Guided exercise: find the patients who need review
+
+[Build your first cohort](first-cohort.md) covers the mechanics of selecting filters. This exercise puts those mechanics to work on a real question:
+
+> **Of these patients, which ones does a human still need to look at?**
+
+You will narrow a cohort, open two patients whose records tell opposite stories, and — at each step — read what the display is actually telling you. Allow about 15 minutes at your own pace.
+
+## Before you start
+
+This exercise is written against the bundled **synthetic demonstration dataset**: 500 breast cancer patients, each with roughly a dozen to two dozen notes. Every count and patient ID below comes from that dataset.
+
+If you are running against different data, the numbers will not match. The exercise still works — see [Adapt this to your own data](#adapt-this-to-your-own-data) at the end.
+
+:::caution Synthetic data
+
+These records are generated. Names, dates of birth, and demographics are fabricated and are **not always internally consistent** with each other — a report header may show a date of birth that disagrees with the patient's age at diagnosis. That inconsistency is an artifact of the sample data, not of the extraction. What is worth studying here is how the Visualizer **represents** what was extracted.
+
+:::
+
+Start with the Cohort Explorer at its unfiltered state. If you have been clicking around, use **Reset filters** in the toolbar first. The toolbar should read **All 500 patients**.
+
+---
+
+## Step 1 — Read the unfiltered screen
+
+Scroll from the top of the page to the bottom without selecting anything.
+
+**What you see.** Filter cards grouped into sections: Patient, Cancer Type & Primary Site, Tumor Anatomy, Staging & Disease Extent, Pathology & Grade, Molecular Markers & Biomarkers, and Treatment & Interventions. Every value on every card is a clinical concept that natural-language processing pulled out of note text.
+
+**Why this matters.** Nothing on this screen was typed into a registry field by a human abstractor. The breadth is the point: staging, biomarkers, treatments, and findings are all queryable at once because they were all extracted from the same notes. That is what makes the next step possible.
+
+---
+
+## Step 2 — Narrow to a clinically meaningful group
+
+In **Staging & Disease Extent**, on the **Stage** card, select `Stage IV`.
+
+**What you see.** Three things change at once:
+
+1. The **Selected Patients** drawer opens at the bottom with **34** matching patients.
+2. The toolbar reports the active criteria: `Filters: Stage (Stage IV)`.
+3. **Every other card on the page repaints**, with each value now showing an in-cohort count over its total.
+
+
+
+The repaint is easiest to read on a card you did not touch. Metastatic Behavior now reports each value as in-cohort over total:
+
+
+
+**Why this matters.** That third change is the one to dwell on. Characterizing a cohort normally means running one query per variable — stage by receptor status, then stage by treatment, then stage by grade. Here, defining the cohort *is* the characterization: you can read its distribution across every extracted concept simultaneously, without leaving the screen or losing your selection.
+
+This also protects you from a common analytic trap. When you have to ask for each cross-tabulation separately, you only ask about variables you already suspect matter. Seeing all of them repaint at once surfaces concentrations you were not looking for.
+
+:::note Combining criteria
+
+Values selected **within one card** are alternatives (OR). Values selected in **different cards** narrow the result (AND). Getting this backwards silently changes what your cohort means, so it is worth confirming — see [Select and combine filters](../cohort-explorer/selecting-filters.md).
+
+:::
+
+### Optional: check the hierarchy
+
+Use the expand control beside `Stage III` on the same card. It opens into `Stage III` and `Stage IIIA`.
+
+**Why this matters.** Selecting the parent includes the specific values underneath it. If it did not, a search for stage III disease would silently omit the stage IIIA patients — and you would have no way to know. A filter that quietly drops patients is a data-correctness failure, not a cosmetic one. Expanding a parent lets you confirm what a selection covers before you rely on it.
+
+---
+
+## Step 3 — Discovery 1: a record that corroborates itself
+
+On the **Metastatic Behavior** card, select `Metastatic` (`7/30`). The cohort narrows from 34 to 7.
+
+In the Selected Patients drawer, expand the row for **`fake_patient125`** and choose **Show in Document Viewer**.
+
+### 3a. Two cancers, one of them a metastasis
+
+**What you see.** **Cancer and Tumor Detail** reports **2 cancers**: Cancer 1 located in **Nipple**, Cancer 2 located in **T 8 Vertebra**.
+
+
+
+**Why this matters.** Breast cancer does not originate in a thoracic vertebra. The structure records two cancer entries, but the clinical relationship between them — primary and metastatic deposit — is not something the structure alone states. This is a case where the extracted framework needs a reader who knows the domain, and it is exactly why the next two steps exist: the display should let you check the interpretation rather than asking you to trust it.
+
+### 3b. The timeline has a shape
+
+**What you see.** The **Patient Document Timeline** holds 18 documents across five years: a dense cluster in spring 2017, roughly four quiet years, then a tight cluster in early 2021 — two radiology reports followed by a surgical pathology report.
+
+
+
+**Why this matters.** You can read the clinical course off the shape before opening a single document. A burst of imaging followed immediately by pathology is a workup; a long quiet stretch is stable disease or lost follow-up; a new burst after quiet is a recurrence being investigated. When you have a cohort of patients to review and limited time, that shape tells you which chart to open first and where in it to start reading.
+
+### 3c. Close the loop on the source note
+
+Select the **Surgical Pathology Report** point dated **2021/03/05**.
+
+**What you see.** The note describes a CT-guided core needle biopsy of a T8 vertebral body lesion, reporting metastatic carcinoma consistent with a breast primary, ER positive, PR positive, HER2 negative. The **Concept List** shows the concepts found in it, including `T8 Vertebra` and `Metastatic`, and each one is highlighted where it appears in the text.
+
+
+
+**Why this matters.** This closes the provenance chain. The "Cancer 2 — T 8 Vertebra" entry you read in Step 3a is now traceable to a specific sentence, in a specific report, on a specific date. Anything derived from extracted concepts — a cohort count, a summary line, a figure in a manuscript — is only as defensible as your ability to get back to the text that produced it. Practice making that trip, because it is the check you will be asked to perform when a result surprises someone.
+
+**This patient is the straightforward case.** The structured record and the source note agree. Now look at one where they do not.
+
+---
+
+## Step 4 — Discovery 2: a record that disagrees with itself
+
+Select `Metastatic` again to clear it, leaving only `Stage IV`. Then, in the **Patient** section on the **Age at Dx** card, select `30-39` — it now reads `1/19`.
+
+**What you see.** **Selected Patients (1)**. Across the page, cards collapse to a single [patient dot](../cohort-explorer/patient-dots.md) each.
+
+**Why this matters.** A cohort of one is not a failed search. Rare intersections are where two interesting things live: genuinely unusual presentations, and extraction errors. Both deserve a human. An exploratory tool should make small-N results easy to reach rather than treating them as a dead end.
+
+Click any dot to open **`fake_patient460`**.
+
+### 4a. The staging contradicts itself
+
+**What you see.** In **Cancer and Tumor Detail**, Cancer 1 carries **TNM: T3, N unknown, M0**. In the **Patient Summary**, the staging section reads **Stage IV**.
+
+**Why this matters.** M0 means no distant metastasis; stage IV means distant metastasis. Both were extracted from this patient's notes, and they cannot both be right. Notice what the Visualizer does *not* do: it does not silently pick a winner, and it does not hide the loser. If the interface resolved this for you, the disagreement would vanish from the record and no one would ever review it. Surfacing the conflict is what makes adjudication possible.
+
+### 4b. Assertions and their negations, side by side
+
+**What you see.** In the Patient Summary, the diagnoses section carries `Metastatic Carcinoma` at 100% confidence **and** `No Metastatic Lesion`. Negated items are struck through and read as "No …". Several items are tagged with an **`uncertain`** or **`conflicted`** chip.
+
+
+
+**Why this matters.** "No metastatic lesion" is not the same as "no information about metastatic lesions." The first is a positive clinical assertion someone wrote in a note; the second is silence. Collapsing those two into one state is among the most consequential errors you can make with NLP-derived data, because it turns a documented rule-out into an apparent absence of evidence — or worse, treats an absence as a rule-out. The Visualizer renders negated items distinctly, and the `conflicted` chip marks where documents disagree with each other.
+
+Treat a `conflicted` or `uncertain` chip as an instruction to open the source, not as a defect in the data.
+
+### 4c. The default view is showing you a subset
+
+**What you see.** In the screenshot above, just below the Patient Summary header: *"41 findings hidden below 100% confidence."* Drag the **Confidence** slider down from 100% toward 50%, and those findings appear.
+
+**Why this matters.** The threshold starts at its maximum, so the summary opens showing only fully confident extractions. This is a deliberate, conservative default — but it means an unaware reader will undercount. If you are asking "does this patient have any documented X?", the answer at 100% and the answer at 60% can differ. Always know which threshold you are reading at before you draw a conclusion, and see [Patient Summary](../explore-patient/patient-summary.md) for how the threshold interacts with findings that carry no confidence score at all.
+
+### 4d. The contradiction is inside a single document
+
+The timeline shows a dense cluster in spring 2014, then over a year of silence, then a cluster in August 2015 and a final pair of notes in November. Select the **Radiology Report** dated **2015/11/01** — the second-to-last point.
+
+**What you see.** In the **Concept List**, concepts extracted from this one report:
+
+| Concept | Status |
+| --- | --- |
+| `Multiple Pulmonary Nodules (2)` | affirmed |
+| `Metastatic Carcinoma (1)` | affirmed |
+| `Metastasis (1)°` | **negated** |
+| `Metastatic Lesion (1)°` | **negated** |
+| `Metastatic Mass (2)°` | **negated** |
+
+The `°` marker denotes a negated mention. Select `Multiple Pulmonary Nodules` and its mentions highlight in the note text.
+
+
+
+Now read the note itself. It describes multiple bilateral pulmonary nodules, qualified as *stable compared to prior imaging* while also noting *none previously noted*. Its impression states that the new nodules are **concerning for metastatic disease**, and then that there is **no evidence of visceral or osseous metastases at this time**.
+
+**Why this matters.** This is the payoff of the whole exercise. The radiologist hedged and, within a single paragraph, contradicted himself. The extraction did not resolve that hedge — it captured both the affirmed and the negated assertions and marked which was which, so the conflict survives all the way from the sentence to the patient summary to the `conflicted` chip you saw in Step 4b.
+
+Had this been flattened into a single `metastatic: true/false` field, this patient would look unremarkable in every cohort query you could write, and no one would open her chart. The value of the display is not that it is certain. It is that it shows you precisely where the certainty runs out.
+
+---
+
+## What this exercise demonstrated
+
+| Step | Capability | The reason it matters |
+| --- | --- | --- |
+| 2 | Every card repaints with in-cohort counts | Characterize a cohort across all variables at once, including ones you were not already suspicious of |
+| 2 | Parent values include their children | A hierarchy that did not roll up would silently drop patients from your result |
+| 3b | Timeline shape | Triage which record to read first, before reading any text |
+| 3c | Concept traced to its source sentence | Extracted facts stay defensible only while they remain checkable |
+| 4 | A cohort of one is reachable | Rare intersections hold both unusual cases and extraction errors |
+| 4a | Conflicting extractions both shown | A resolved conflict is an invisible conflict |
+| 4b | Negation rendered distinctly | A documented rule-out is not the same as missing information |
+| 4c | Confidence threshold starts at 100% | The default view is a subset; know your threshold before concluding |
+| 4d | Affirmed and negated mentions in one note | Uncertainty in the source survives into the display instead of being flattened |
+
+---
+
+## Adapt this to your own data
+
+The click path matters more than the specific patients. To reproduce the exercise on a different dataset:
+
+1. **For Discovery 1**, look for a patient with more than one cancer entry where the locations are anatomically inconsistent with a single primary, and whose timeline shows a gap followed by a cluster. Sort the Selected Patients table by document count and start with the richest records.
+2. **For Discovery 2**, narrow to a stage or behavior value, then add a demographic filter until the cohort reaches one or two patients. Open them and look for `conflicted` or `uncertain` chips in the Patient Summary, or for a TNM value that disagrees with the recorded stage.
+3. **In either case**, lower the Confidence slider before concluding a patient is uninteresting. The most instructive records are often the ones whose contradictions sit below 100%.
+
+## Presenting this as a demonstration
+
+The exercise compresses to roughly five minutes if you keep moving:
+
+| Time | Section |
+| --- | --- |
+| 0:00–0:30 | Steps 1 and the synthetic-data caveat |
+| 0:30–1:15 | Step 2 — select `Stage IV`, point out the repaint |
+| 1:15–2:15 | Step 3 — `fake_patient125`, ending on the 2021 pathology report |
+| 2:15–4:30 | Step 4 — `fake_patient460`, ending on the November 2015 radiology report |
+| 4:30–5:00 | Close on the summary table above |
+
+If you fall behind, cut Step 4c — the concept list in Step 4d carries the argument on its own. Keep **Reset filters** in reach as a recovery step.
+
+## More exercises
+
+- [Build a targeted-therapy cohort](exercise-targeted-therapy.md) — assemble a HER2-drug cohort through the search dialog, check its biomarkers, and export it
+- [Compare treatment outcomes](exercise-compare-outcomes.md) — hold complete responders against progressors and read the difference off the cards
+
+## Next steps
+
+- [Understand cohort results](../cohort-explorer/understanding-results.md) — how counts are computed and what an empty result means
+- [Patient Summary](../explore-patient/patient-summary.md) — confidence, negation, and source links in depth
+- [Use the Document Viewer](../explore-patient/document-viewer.md) — concept groups, confidence filtering, and mention details
+- [Export results](../cohort-explorer/exporting-results.md) — save a cohort for analysis elsewhere
diff --git a/docs/index.md b/docs/index.md
index ea7049b..e62bde6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,6 +17,12 @@ Most work happens in the **Cohort Explorer**, the screen that opens when you sta
New to the Visualizer? Begin with [Cohort Explorer overview](getting-started/overview.md) and [Build your first cohort](getting-started/first-cohort.md).
+Once the basics make sense, work through the guided exercises — each walks a real question through the whole workflow and explains why every finding matters:
+
+- [Find the patients who need review](getting-started/guided-exercise.md) — narrow a cohort down to two contrasting patient records
+- [Build a targeted-therapy cohort](getting-started/exercise-targeted-therapy.md) — assemble a HER2-drug group, check its biomarkers, and export it
+- [Compare treatment outcomes](getting-started/exercise-compare-outcomes.md) — hold complete responders against progressors
+
## What you can do
The guide follows the tasks you do, in order:
diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs
index 40dc2d1..ca94a36 100755
--- a/scripts/capture-screenshots.mjs
+++ b/scripts/capture-screenshots.mjs
@@ -21,6 +21,36 @@ const DOC_PATIENT_ID = process.env.DOC_PATIENT_ID || "fake_patient3";
// timeline's episode-dropdown fallback. Optional; skipped if it does not resolve.
const COLLAPSED_DATE_PATIENT_ID = process.env.COLLAPSED_DATE_PATIENT_ID || "fake_patient7";
+// The two patients the guided exercise walks through. The first has a record
+// whose structure and source notes agree; the second carries contradictory
+// extractions (a TNM value that disagrees with its stage, and a note that both
+// asserts and negates metastatic disease). Both are dataset-specific — override
+// them, or accept that the exercise captures fall back to prose.
+// See docs/getting-started/guided-exercise.md.
+const EXERCISE_CORROBORATED_PATIENT_ID =
+ process.env.EXERCISE_CORROBORATED_PATIENT_ID || "fake_patient125";
+const EXERCISE_CONFLICTED_PATIENT_ID =
+ process.env.EXERCISE_CONFLICTED_PATIENT_ID || "fake_patient460";
+// The staging value the exercise filters on, and a second card that visibly
+// repaints with in-cohort counts once that filter is active.
+const EXERCISE_STAGE_VALUE = process.env.EXERCISE_STAGE_VALUE || "Stage IV";
+
+// The targeted-therapy exercise (docs/getting-started/exercise-targeted-therapy.md)
+// builds a HER2-drug cohort through the Treatments search dialog, then reads the
+// biomarker repaint. Data-specific — override for a different dataset.
+const THERAPY_DRUG = process.env.THERAPY_DRUG || "Trastuzumab";
+
+// The treatment-outcome exercise (docs/getting-started/exercise-compare-outcomes.md)
+// contrasts two response values from the Clinical Course of Disease card.
+const OUTCOME_RESPONDER_VALUE =
+ process.env.OUTCOME_RESPONDER_VALUE || "Pathologic Complete Response";
+const OUTCOME_PROGRESSOR_VALUE =
+ process.env.OUTCOME_PROGRESSOR_VALUE || "Progressive Disease";
+const OUTCOME_COURSE_CARD = process.env.OUTCOME_COURSE_CARD || "Clinical Course of Disease";
+// The age band that, combined with the staging value, narrows the cohort far
+// enough for the conflicted patient to appear as a clickable patient dot.
+const EXERCISE_AGE_BAND = process.env.EXERCISE_AGE_BAND || "30-39";
+
// Feature-documentation capture set. `REQUIRED_SCREENSHOTS` back pages that
// always show the image and must capture cleanly. `OPTIONAL_SCREENSHOTS` back
// newer interaction captures that can be data- or environment-dependent; a
@@ -60,10 +90,38 @@ const OPTIONAL_SCREENSHOTS = [
"50-csv-export-button.png",
"51-filter-hierarchical-values.png",
"52-filter-disabled-values.png",
+ "60-exercise-stage-filter-selected.png",
+ "61-exercise-cross-filter-counts.png",
+ "62-exercise-cancer-tumor-detail.png",
+ "63-exercise-relapse-timeline.png",
+ "64-exercise-source-pathology.png",
+ "65-exercise-conflicted-summary.png",
+ "66-exercise-negated-concepts.png",
+ "70-therapy-treatment-search.png",
+ "71-therapy-gene-repaint.png",
+ "72-therapy-her2-gap.png",
+ "73-therapy-cohort-table.png",
+ "80-outcome-responders-stage.png",
+ "81-outcome-responders-behavior.png",
+ "82-outcome-progressors-stage.png",
+ "83-outcome-progressors-behavior.png",
];
const SCREENSHOT_ORDER = [...REQUIRED_SCREENSHOTS, ...OPTIONAL_SCREENSHOTS];
+// Subset filter. `CAPTURE_ONLY` takes a comma-separated list of file-name
+// fragments (for example "exercise" or "42-document-viewer"); when set, only
+// matching captures are written. Re-taking one data-dependent series is then
+// possible without re-shooting — and potentially degrading — the whole set.
+const CAPTURE_ONLY = (process.env.CAPTURE_ONLY || "")
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+
+function isRequested(file) {
+ return CAPTURE_ONLY.length === 0 || CAPTURE_ONLY.some((fragment) => file.includes(fragment));
+}
+
const summary = {
generatedAt: new Date().toISOString(),
baseUrl: BASE_URL,
@@ -132,6 +190,10 @@ async function captureLocatorOrFallback(page, locator, name, fallbackFullPage =
}
async function withCapture(page, config) {
+ if (!isRequested(config.file)) {
+ return;
+ }
+
console.log(`Capturing ${config.file} (${config.route})`);
const entry = {
file: config.file,
@@ -768,6 +830,580 @@ async function captureStandaloneSeries(page) {
});
}
+// Select one value on a named filter card. Bar overlays are SVG rects whose
+// React handler only fires on keyboard activation, so focus and press Enter
+// rather than clicking — the same approach as activateFilterSelection.
+async function selectFilterValue(page, filterName, value) {
+ const card = filterCardLocator(page, filterName);
+ if (!(await waitForLocator(card, 10000))) {
+ throw new Error(`${filterName} filter card not found`);
+ }
+
+ await card.scrollIntoViewIfNeeded().catch(() => {});
+ await sleep(250);
+
+ const bar = card
+ .locator(`.horizontal-bar-filter-row-overlay[role='button'][aria-label^="${value}:"]`)
+ .first();
+ if ((await bar.count()) === 0) {
+ throw new Error(`Value "${value}" not found on the ${filterName} card`);
+ }
+
+ await bar.focus().catch(() => {});
+ await page.keyboard.press("Enter");
+ await sleep(1200);
+
+ const label = (await bar.getAttribute("aria-label")) || "";
+ if (!/\bSelected\b/i.test(label)) {
+ throw new Error(`Value "${value}" did not enter the selected state`);
+ }
+
+ return card;
+}
+
+// Scroll a target so its top sits near the top of the viewport. Filter cards
+// captured while the Selected Patients drawer is open would otherwise be
+// overlapped by it — the drawer is fixed to the bottom of the window, and an
+// element screenshot renders whatever covers the element's box.
+async function scrollElementClearOfDrawer(page, locator, topMargin = 120) {
+ await locator
+ .evaluate((element, margin) => {
+ const { top } = element.getBoundingClientRect();
+ window.scrollBy({ top: top - margin, left: 0, behavior: "instant" });
+ }, topMargin)
+ .catch(() => {});
+ await sleep(400);
+}
+
+// Open the most recent document of a given type from the patient timeline.
+// Points are rendered in date order, so the last match is the latest one.
+async function selectLatestTimelineDocument(page, typeLabel) {
+ const points = page.locator(`circle[aria-label*="Type ${typeLabel}."]`);
+ const count = await points.count();
+ if (count === 0) {
+ throw new Error(`No "${typeLabel}" points on the document timeline`);
+ }
+
+ const target = points.nth(count - 1);
+ await target.scrollIntoViewIfNeeded().catch(() => {});
+ await target.click({ force: true });
+
+ const loaded = await waitForLocator(
+ page.getByText(`Selected:`, { exact: false }).first(),
+ 8000
+ );
+ if (!loaded) {
+ throw new Error(`Selecting a "${typeLabel}" point did not load a document`);
+ }
+ await sleep(900);
+}
+
+// The guided exercise (docs/getting-started/guided-exercise.md) teaches cohort
+// cross-filtering and then contrasts two patient records. These captures are
+// data-dependent by nature — they depend on a specific staging value and two
+// specific patients — so every one of them is optional.
+async function captureGuidedExerciseSeries(page) {
+ // Step 2: the Stage card with the exercise's staging value selected, and a
+ // second card showing the in-cohort/total counts that selection produces.
+ // The selection happens once, outside withCapture, because both captures
+ // depend on it — withCapture handles its own failures and never rethrows.
+ await gotoRoute(page, "/");
+
+ let stageSelectionError = "";
+ let stageCard = null;
+ try {
+ stageCard = await selectFilterValue(page, "Stage", EXERCISE_STAGE_VALUE);
+ } catch (error) {
+ stageSelectionError = error instanceof Error ? error.message : String(error);
+ }
+
+ await withCapture(page, {
+ file: "60-exercise-stage-filter-selected.png",
+ route: "/",
+ target: `Stage card with ${EXERCISE_STAGE_VALUE} selected`,
+ optional: true,
+ run: async () => {
+ if (!stageCard) {
+ throw new Error(stageSelectionError);
+ }
+ await scrollElementClearOfDrawer(page, stageCard);
+ await captureLocatorOrFallback(page, stageCard, "60-exercise-stage-filter-selected.png", false);
+ },
+ });
+
+ await withCapture(page, {
+ file: "61-exercise-cross-filter-counts.png",
+ route: "/",
+ target: "Metastatic Behavior card showing in-cohort counts",
+ optional: true,
+ run: async () => {
+ if (!stageCard) {
+ throw new Error(`"${EXERCISE_STAGE_VALUE}" could not be selected: ${stageSelectionError}`);
+ }
+ const card = filterCardLocator(page, "Metastatic Behavior");
+ if (!(await waitForLocator(card, 8000))) {
+ throw new Error("Metastatic Behavior card not found");
+ }
+ await scrollElementClearOfDrawer(page, card);
+ await captureLocatorOrFallback(page, card, "61-exercise-cross-filter-counts.png", false);
+ },
+ });
+
+ // Steps 3a–3c: the record whose structure and source notes agree.
+ const corroboratedFiles = [
+ "62-exercise-cancer-tumor-detail.png",
+ "63-exercise-relapse-timeline.png",
+ "64-exercise-source-pathology.png",
+ ];
+
+ try {
+ await loadStandalonePatient(page, EXERCISE_CORROBORATED_PATIENT_ID);
+ } catch (error) {
+ for (const file of corroboratedFiles) {
+ await withCapture(page, {
+ file,
+ route: "/patient",
+ target: "Guided exercise patient (unavailable)",
+ optional: true,
+ run: async () => {
+ throw new Error(`Patient ${EXERCISE_CORROBORATED_PATIENT_ID} did not load: ${error.message}`);
+ },
+ });
+ }
+ return captureGuidedExerciseConflicted(page);
+ }
+
+ await withCapture(page, {
+ file: "62-exercise-cancer-tumor-detail.png",
+ route: "/patient",
+ target: "Cancer and Tumor Detail with a metastatic second cancer",
+ optional: true,
+ run: async () => {
+ const card = page.locator('.MuiCard-root:has(:text("Cancer and Tumor Detail"))').first();
+ if (!(await waitForLocator(card, 8000))) {
+ throw new Error("Cancer and Tumor Detail card not found");
+ }
+ await captureLocatorOrFallback(page, card, "62-exercise-cancer-tumor-detail.png", false);
+ },
+ });
+
+ await withCapture(page, {
+ file: "63-exercise-relapse-timeline.png",
+ route: "/patient",
+ target: "Document timeline showing a gap followed by a relapse cluster",
+ optional: true,
+ run: async () => {
+ const card = page.locator('.MuiCard-root:has(:text("Patient Document Timeline"))').first();
+ if (!(await waitForLocator(card, 8000))) {
+ throw new Error("Patient Document Timeline card not found");
+ }
+ await card.scrollIntoViewIfNeeded().catch(() => {});
+ await sleep(300);
+ await captureLocatorOrFallback(page, card, "63-exercise-relapse-timeline.png", false);
+ },
+ });
+
+ await withCapture(page, {
+ file: "64-exercise-source-pathology.png",
+ route: "/patient",
+ target: "Document Viewer on the most recent pathology report",
+ optional: true,
+ run: async () => {
+ await selectLatestTimelineDocument(page, "Surgical Pathology Report");
+ const card = documentViewerCard(page);
+ if ((await card.count()) === 0) {
+ throw new Error("Document Viewer card not found");
+ }
+ await card.scrollIntoViewIfNeeded().catch(() => {});
+ await sleep(300);
+ await captureLocatorOrFallback(page, card, "64-exercise-source-pathology.png", false);
+ },
+ });
+
+ return captureGuidedExerciseConflicted(page);
+}
+
+// Steps 4b–4d: the record that disagrees with itself.
+//
+// The Patient Summary card exists only in the embedded (drawer) patient view,
+// not on the standalone /patient route, so this capture reaches the patient the
+// way the exercise does: narrow the cohort until the patient renders as a dot,
+// then click the dot to open their tab in the drawer.
+async function captureGuidedExerciseConflicted(page) {
+ await withCapture(page, {
+ file: "65-exercise-conflicted-summary.png",
+ route: "/",
+ target: "Patient Summary with conflicted and negated findings",
+ optional: true,
+ run: async () => {
+ await gotoRoute(page, "/");
+ await selectFilterValue(page, "Stage", EXERCISE_STAGE_VALUE);
+ await selectFilterValue(page, "Age at Dx", EXERCISE_AGE_BAND);
+
+ // Only this patient's dot will do. Dots are drawn per filter value from
+ // that value's own patient list, so the page is full of dots belonging to
+ // other patients — taking "any" dot would silently capture the wrong
+ // record. Wait for the cohort to settle rather than reading the count
+ // immediately: dots appear only once patient IDs have loaded.
+ const dot = page
+ .locator(`[role="button"][aria-label^="Patient ${EXERCISE_CONFLICTED_PATIENT_ID}."]`)
+ .first();
+ if (!(await waitForLocator(dot, 15000))) {
+ throw new Error(
+ `No patient dot for ${EXERCISE_CONFLICTED_PATIENT_ID} under ` +
+ `${EXERCISE_STAGE_VALUE} + ${EXERCISE_AGE_BAND}`
+ );
+ }
+
+ // Activate with the keyboard, not a click. Dots are small, densely packed
+ // SVG circles and the drawer overlays the lower page, so a forced click
+ // can land on a neighbouring dot and open the wrong patient.
+ await dot.scrollIntoViewIfNeeded().catch(() => {});
+ await dot.focus();
+ await page.keyboard.press("Enter");
+
+ const drawer = page.locator("[data-testid='patient-grid-drawer']").first();
+ if (!(await waitForLocator(drawer, 10000))) {
+ throw new Error("Patient drawer did not open after activating the patient dot");
+ }
+
+ // Confirm the drawer really opened the intended patient before capturing.
+ const patientTab = drawer
+ .getByRole("tab", { name: new RegExp(EXERCISE_CONFLICTED_PATIENT_ID) })
+ .first();
+ if (!(await waitForLocator(patientTab, 10000))) {
+ throw new Error(
+ `Drawer did not open a tab for ${EXERCISE_CONFLICTED_PATIENT_ID}`
+ );
+ }
+ await drawer
+ .locator("[role='progressbar']")
+ .first()
+ .waitFor({ state: "hidden", timeout: 15000 })
+ .catch(() => {});
+ await sleep(700);
+
+ // The scroll region — and the confidence slider with its hidden-findings
+ // count — render only while the section is expanded.
+ const expandToggle = page
+ .locator('button[aria-label="Expand Patient Summary section"]')
+ .first();
+ if ((await expandToggle.count()) > 0) {
+ await expandToggle.click();
+ await sleep(600);
+ }
+
+ // Same locator as 33-patient-summary-card: the title is a CardHeader span,
+ // and the scroll region only exists when structured sections are present.
+ const card = page
+ .locator('.MuiCard-root:has([data-testid="patient-summary-card-scroll"])')
+ .first();
+ if (!(await waitForLocator(card, 8000))) {
+ throw new Error("Patient Summary card not found or has no summary sections");
+ }
+ await card.scrollIntoViewIfNeeded().catch(() => {});
+ await sleep(300);
+ await captureLocatorOrFallback(page, card, "65-exercise-conflicted-summary.png", false);
+ },
+ });
+
+ // The Document Viewer does render standalone, so the concept-list capture
+ // uses the deterministic /patient route.
+ await withCapture(page, {
+ file: "66-exercise-negated-concepts.png",
+ route: "/patient",
+ target: "Concept List showing affirmed and negated mentions in one note",
+ optional: true,
+ run: async () => {
+ await loadStandalonePatient(page, EXERCISE_CONFLICTED_PATIENT_ID);
+ await selectLatestTimelineDocument(page, "Radiology Report");
+ const card = documentViewerCard(page);
+ if ((await card.count()) === 0) {
+ throw new Error("Document Viewer card not found");
+ }
+ await card.scrollIntoViewIfNeeded().catch(() => {});
+ await sleep(300);
+ await captureLocatorOrFallback(page, card, "66-exercise-negated-concepts.png", false);
+ },
+ });
+}
+
+// Open a filter card's Details dialog and type a search term. Returns the dialog
+// locator (matching rows visible) without selecting anything, so the caller can
+// both capture the search state and then select a value.
+async function openFilterDialogAndSearch(page, filterName, searchTerm) {
+ const openButton = page.locator(`button[aria-label="Open ${filterName} filter"]`).first();
+ if (!(await waitForLocator(openButton, 8000))) {
+ throw new Error(`"Open ${filterName} filter" button not found`);
+ }
+ await openButton.scrollIntoViewIfNeeded().catch(() => {});
+ await openButton.click();
+
+ const dialog = page.getByRole("dialog").first();
+ if (!(await waitForLocator(dialog, 6000))) {
+ throw new Error(`${filterName} details dialog did not open`);
+ }
+ // The input's accessible name comes from its inputProps aria-label
+ // ("Search filter values"), which overrides the visible "Search values"
+ // TextField label — so match the input directly.
+ const search = dialog
+ .locator('input[aria-label="Search filter values"], input[placeholder="Type to filter labels"]')
+ .first();
+ if (!(await waitForLocator(search, 6000))) {
+ throw new Error("Search field not found in the details dialog");
+ }
+ await search.fill(searchTerm);
+ await sleep(600);
+ return dialog;
+}
+
+// Open a filter card's Details dialog without searching. Used when the whole
+// value list is wanted — e.g. the Stage dialog, which lists every stage in a
+// scrollable list and clearly dims unavailable values, where the compact card
+// clips its fourth row once values render as (taller) patient-dot rows.
+async function openFilterDialog(page, filterName) {
+ const openButton = page.locator(`button[aria-label="Open ${filterName} filter"]`).first();
+ if (!(await waitForLocator(openButton, 8000))) {
+ throw new Error(`"Open ${filterName} filter" button not found`);
+ }
+ await openButton.scrollIntoViewIfNeeded().catch(() => {});
+ await openButton.click();
+ const dialog = page.getByRole("dialog").first();
+ if (!(await waitForLocator(dialog, 6000))) {
+ throw new Error(`${filterName} details dialog did not open`);
+ }
+ await sleep(500);
+ return dialog;
+}
+
+// Close an open details dialog so it does not overlay later captures.
+async function closeDialog(page, dialog) {
+ const closeButton = dialog.getByRole("button", { name: /close/i }).first();
+ if ((await closeButton.count()) > 0) {
+ await closeButton.click();
+ } else {
+ await page.keyboard.press("Escape").catch(() => {});
+ }
+ await sleep(400);
+}
+
+// The targeted-therapy exercise: build a HER2-drug cohort through the Treatments
+// search dialog (the facet is far too long to scroll), then read the biomarker
+// repaint as a data-quality check, and finish in the patient table. Every capture
+// is data-dependent and therefore optional.
+async function captureTargetedTherapySeries(page) {
+ await gotoRoute(page, "/");
+
+ // Open the Treatments dialog and search for the drug. The selection is applied
+ // here (outside withCapture, which never rethrows) so later cards can repaint.
+ let therapyError = "";
+ let therapySelected = false;
+ let dialog = null;
+ try {
+ dialog = await openFilterDialogAndSearch(page, "Treatments", THERAPY_DRUG);
+ } catch (error) {
+ therapyError = error instanceof Error ? error.message : String(error);
+ }
+
+ await withCapture(page, {
+ file: "70-therapy-treatment-search.png",
+ route: "/",
+ target: `Treatments details dialog searched for ${THERAPY_DRUG}`,
+ optional: true,
+ run: async () => {
+ if (!dialog) {
+ throw new Error(therapyError);
+ }
+ await captureLocatorOrFallback(page, dialog, "70-therapy-treatment-search.png", false);
+ },
+ });
+
+ if (dialog) {
+ try {
+ // Select the exact-drug row (not a combination regimen containing the name).
+ const row = dialog
+ .locator(`[role="button"][aria-label^="${THERAPY_DRUG}:"]`)
+ .first();
+ if (!(await waitForLocator(row, 5000))) {
+ throw new Error(`No "${THERAPY_DRUG}" row in the dialog`);
+ }
+ await row.click();
+ await sleep(600);
+ await closeDialog(page, dialog);
+ await sleep(400);
+ therapySelected = true;
+ } catch (error) {
+ therapyError = error instanceof Error ? error.message : String(error);
+ }
+ }
+
+ // The gene enrichment: with the drug cohort active, the Genes details dialog
+ // reports how many of them carry the HER2/ERBB2 gene, as in-cohort / total.
+ // The dialog is used rather than the compact card because the card renders
+ // only its first few (alphabetical) rows, which can bury the ERBB2 row.
+ await withCapture(page, {
+ file: "71-therapy-gene-repaint.png",
+ route: "/",
+ target: "Genes details dialog showing ERBB2 in-cohort count",
+ optional: true,
+ run: async () => {
+ if (!therapySelected) {
+ throw new Error(`"${THERAPY_DRUG}" could not be selected: ${therapyError}`);
+ }
+ const genesDialog = await openFilterDialogAndSearch(page, "Genes", "ERBB");
+ const erbbRow = genesDialog.locator('[aria-label^="ERBB 2 Gene:"]').first();
+ if (!(await waitForLocator(erbbRow, 5000))) {
+ throw new Error("ERBB2 row not found in the Genes dialog");
+ }
+ await captureLocatorOrFallback(page, genesDialog, "71-therapy-gene-repaint.png", false);
+ // Close so the dialog does not overlay the following card captures.
+ await closeDialog(page, genesDialog);
+ },
+ });
+
+ // The gap: a recorded HER2 status finding exists for far fewer patients.
+ await withCapture(page, {
+ file: "72-therapy-her2-gap.png",
+ route: "/",
+ target: "HER2/Neu Status card showing partial coverage",
+ optional: true,
+ run: async () => {
+ if (!therapySelected) {
+ throw new Error(`"${THERAPY_DRUG}" could not be selected: ${therapyError}`);
+ }
+ const card = filterCardLocator(page, "HER2/Neu Status");
+ if (!(await waitForLocator(card, 8000))) {
+ throw new Error("HER2/Neu Status card not found");
+ }
+ await scrollElementClearOfDrawer(page, card);
+ await captureLocatorOrFallback(page, card, "72-therapy-her2-gap.png", false);
+ },
+ });
+
+ // The patient table: columns (incl. Biomarkers / Treatments), the column
+ // chooser, and CSV export, sorted by document count.
+ await withCapture(page, {
+ file: "73-therapy-cohort-table.png",
+ route: "/",
+ target: "Selected Patients table with CSV export, sorted by document count",
+ optional: true,
+ run: async () => {
+ if (!therapySelected) {
+ throw new Error(`"${THERAPY_DRUG}" could not be selected: ${therapyError}`);
+ }
+ const region = page.locator("[data-testid='patient-grid-embedded']").first();
+ if (!(await waitForLocator(region, 12000))) {
+ throw new Error("Selected Patients table not visible");
+ }
+ // Sort by document count so the richest records lead (two clicks =
+ // descending). Best effort — the capture is worthwhile even unsorted.
+ const header = region.locator('th:has-text("Document Count"), [role="columnheader"]:has-text("Document Count")').first();
+ if ((await header.count()) > 0) {
+ await header.click().catch(() => {});
+ await sleep(300);
+ await header.click().catch(() => {});
+ await sleep(500);
+ }
+ await region.scrollIntoViewIfNeeded().catch(() => {});
+ await sleep(300);
+ await captureLocatorOrFallback(page, region, "73-therapy-cohort-table.png", false);
+ },
+ });
+}
+
+// The treatment-outcome exercise: contrast two response groups. The headline is
+// the Stage card, where a whole stage value goes disabled for each group in a
+// different place. All captures are data-dependent and optional.
+async function captureOutcomeComparisonSeries(page) {
+ // Responders.
+ await gotoRoute(page, "/");
+ let responderError = "";
+ let responderSelected = false;
+ try {
+ await selectFilterValue(page, OUTCOME_COURSE_CARD, OUTCOME_RESPONDER_VALUE);
+ responderSelected = true;
+ } catch (error) {
+ responderError = error instanceof Error ? error.message : String(error);
+ }
+
+ await withCapture(page, {
+ file: "80-outcome-responders-stage.png",
+ route: "/",
+ target: `Stage dialog for ${OUTCOME_RESPONDER_VALUE} (Stage IV disabled)`,
+ optional: true,
+ run: async () => {
+ if (!responderSelected) {
+ throw new Error(responderError);
+ }
+ const dialog = await openFilterDialog(page, "Stage");
+ await captureLocatorOrFallback(page, dialog, "80-outcome-responders-stage.png", false);
+ await closeDialog(page, dialog);
+ },
+ });
+
+ await withCapture(page, {
+ file: "81-outcome-responders-behavior.png",
+ route: "/",
+ target: `Metastatic Behavior card for ${OUTCOME_RESPONDER_VALUE}`,
+ optional: true,
+ run: async () => {
+ if (!responderSelected) {
+ throw new Error(responderError);
+ }
+ const card = filterCardLocator(page, "Metastatic Behavior");
+ if (!(await waitForLocator(card, 8000))) {
+ throw new Error("Metastatic Behavior card not found");
+ }
+ await scrollElementClearOfDrawer(page, card);
+ await captureLocatorOrFallback(page, card, "81-outcome-responders-behavior.png", false);
+ },
+ });
+
+ // Progressors.
+ await gotoRoute(page, "/");
+ let progressorError = "";
+ let progressorSelected = false;
+ try {
+ await selectFilterValue(page, OUTCOME_COURSE_CARD, OUTCOME_PROGRESSOR_VALUE);
+ progressorSelected = true;
+ } catch (error) {
+ progressorError = error instanceof Error ? error.message : String(error);
+ }
+
+ await withCapture(page, {
+ file: "82-outcome-progressors-stage.png",
+ route: "/",
+ target: `Stage dialog for ${OUTCOME_PROGRESSOR_VALUE} (Stage IV present)`,
+ optional: true,
+ run: async () => {
+ if (!progressorSelected) {
+ throw new Error(progressorError);
+ }
+ const dialog = await openFilterDialog(page, "Stage");
+ await captureLocatorOrFallback(page, dialog, "82-outcome-progressors-stage.png", false);
+ await closeDialog(page, dialog);
+ },
+ });
+
+ await withCapture(page, {
+ file: "83-outcome-progressors-behavior.png",
+ route: "/",
+ target: `Metastatic Behavior card for ${OUTCOME_PROGRESSOR_VALUE}`,
+ optional: true,
+ run: async () => {
+ if (!progressorSelected) {
+ throw new Error(progressorError);
+ }
+ const card = filterCardLocator(page, "Metastatic Behavior");
+ if (!(await waitForLocator(card, 8000))) {
+ throw new Error("Metastatic Behavior card not found");
+ }
+ await scrollElementClearOfDrawer(page, card);
+ await captureLocatorOrFallback(page, card, "83-outcome-progressors-behavior.png", false);
+ },
+ });
+}
+
async function captureCollapsedDateTimeline(page) {
await withCapture(page, {
file: "45-collapsed-date-episode-controls.png",
@@ -1131,11 +1767,27 @@ async function run() {
await captureEmbeddedPatientViewSeries(page);
+ // Guided-exercise series: cohort cross-filtering, then two contrasting
+ // patient records. Runs before the zero-result capture because it needs a
+ // non-empty cohort.
+ await captureGuidedExerciseSeries(page);
+
+ // Additional guided exercises: a targeted-therapy cohort, and a
+ // treatment-outcome comparison. Both need a populated cohort, so they also
+ // run before the zero-result capture.
+ await captureTargetedTherapySeries(page);
+ await captureOutcomeComparisonSeries(page);
+
// Zero-result guidance runs last — it deliberately empties the cohort.
+ await gotoRoute(page, "/");
await captureZeroResultGuidance(page);
// Post-run validation: every targeted screenshot must exist on disk.
for (const file of SCREENSHOT_ORDER) {
+ if (!isRequested(file)) {
+ continue;
+ }
+
const exists = await fs
.access(filePath(file))
.then(() => true)
@@ -1154,7 +1806,8 @@ async function run() {
await fs.writeFile(SUMMARY_PATH, JSON.stringify(summary, null, 2));
- const total = SCREENSHOT_ORDER.length;
+ const targeted = SCREENSHOT_ORDER.filter(isRequested);
+ const requiredTargeted = REQUIRED_SCREENSHOTS.filter(isRequested);
const requiredFailures = failures.filter(
(entry) => !entry.optional && !OPTIONAL_SCREENSHOTS.includes(entry.file)
);
@@ -1162,7 +1815,8 @@ async function run() {
(entry) => entry.optional || OPTIONAL_SCREENSHOTS.includes(entry.file)
);
console.log(
- `Capture complete. Targeted: ${total} (${REQUIRED_SCREENSHOTS.length} required). ` +
+ `Capture complete. Targeted: ${targeted.length} (${requiredTargeted.length} required)` +
+ `${CAPTURE_ONLY.length > 0 ? ` — filtered by CAPTURE_ONLY=${CAPTURE_ONLY.join(",")}` : ""}. ` +
`Required issues: ${requiredFailures.length}. Optional issues: ${optionalFailures.length}.`
);
console.log(`Summary written to ${SUMMARY_PATH}`);
diff --git a/scripts/prepare-docs.mjs b/scripts/prepare-docs.mjs
index d60cdc2..5c15819 100644
--- a/scripts/prepare-docs.mjs
+++ b/scripts/prepare-docs.mjs
@@ -53,6 +53,21 @@ const SCREENSHOT_MAP = [
["50-csv-export-button.png", "csv-export-button.png", false],
["51-filter-hierarchical-values.png", "filter-hierarchical-values.png", false],
["52-filter-disabled-values.png", "filter-disabled-values.png", false],
+ ["60-exercise-stage-filter-selected.png", "exercise-stage-filter-selected.png", false],
+ ["61-exercise-cross-filter-counts.png", "exercise-cross-filter-counts.png", false],
+ ["62-exercise-cancer-tumor-detail.png", "exercise-cancer-tumor-detail.png", false],
+ ["63-exercise-relapse-timeline.png", "exercise-relapse-timeline.png", false],
+ ["64-exercise-source-pathology.png", "exercise-source-pathology.png", false],
+ ["65-exercise-conflicted-summary.png", "exercise-conflicted-summary.png", false],
+ ["66-exercise-negated-concepts.png", "exercise-negated-concepts.png", false],
+ ["70-therapy-treatment-search.png", "therapy-treatment-search.png", false],
+ ["71-therapy-gene-repaint.png", "therapy-gene-repaint.png", false],
+ ["72-therapy-her2-gap.png", "therapy-her2-gap.png", false],
+ ["73-therapy-cohort-table.png", "therapy-cohort-table.png", false],
+ ["80-outcome-responders-stage.png", "outcome-responders-stage.png", false],
+ ["81-outcome-responders-behavior.png", "outcome-responders-behavior.png", false],
+ ["82-outcome-progressors-stage.png", "outcome-progressors-stage.png", false],
+ ["83-outcome-progressors-behavior.png", "outcome-progressors-behavior.png", false],
];
function findSource(captureName, semanticName) {
diff --git a/src/App.js b/src/App.js
index b17fe6d..517cc52 100644
--- a/src/App.js
+++ b/src/App.js
@@ -6,6 +6,7 @@ import AccessibilityStatement from "./views/AccessibilityStatement";
import FilterSetsConfigView from "./views/FilterSetsConfigView";
import PatientView from "./views/patient";
import PerfPanel from "./components/PerfPanel";
+import FeedbackWidget from "./components/FeedbackWidget";
const SHOW_PERF_TRACKER = false;
@@ -49,6 +50,7 @@ function App() {
} />
{SHOW_PERF_TRACKER ? : null}
+
);
}
diff --git a/src/components/FeedbackWidget.jsx b/src/components/FeedbackWidget.jsx
new file mode 100644
index 0000000..3a2bb1d
--- /dev/null
+++ b/src/components/FeedbackWidget.jsx
@@ -0,0 +1,159 @@
+import React, { useState } from "react";
+import {
+ Alert,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Fab,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { ThemeProvider } from "@mui/material/styles";
+import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined";
+import { THEME_STORAGE_KEY, getThemeByKey } from "../themes";
+
+// The endpoint is provided by the deployment (e.g. the demo's viz-server), not
+// the app itself. On deployments without it the POST simply fails and the user
+// sees a retry message — the widget stays self-contained.
+const FEEDBACK_ENDPOINT = "/feedback";
+const CLOSE_AFTER_SUCCESS_MS = 1500;
+
+function resolveTheme() {
+ try {
+ return getThemeByKey(localStorage.getItem(THEME_STORAGE_KEY) || "obsidian");
+ } catch (error) {
+ return getThemeByKey("obsidian");
+ }
+}
+
+function FeedbackWidget() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [text, setText] = useState("");
+ const [email, setEmail] = useState("");
+ const [status, setStatus] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const theme = resolveTheme();
+
+ const closeDialog = () => {
+ if (isSubmitting) {
+ return;
+ }
+ setIsOpen(false);
+ };
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ const trimmed = text.trim();
+ if (!trimmed) {
+ setStatus({ type: "warning", message: "Please add a note before sending." });
+ return;
+ }
+
+ setIsSubmitting(true);
+ setStatus(null);
+
+ try {
+ const response = await fetch(FEEDBACK_ENDPOINT, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ text: trimmed,
+ email: email.trim(),
+ path: window.location.pathname + window.location.search,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Feedback request failed: ${response.status}`);
+ }
+
+ setStatus({ type: "success", message: "Thanks — your feedback was sent." });
+ setText("");
+ setEmail("");
+ window.setTimeout(() => setIsOpen(false), CLOSE_AFTER_SUCCESS_MS);
+ } catch (error) {
+ setStatus({
+ type: "error",
+ message: "Sorry, that did not send. Please try again.",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ setIsOpen(true)}
+ aria-label="Give feedback about this tool"
+ sx={{
+ position: "fixed",
+ bottom: 24,
+ right: 24,
+ zIndex: (muiTheme) => muiTheme.zIndex.speedDial,
+ textTransform: "none",
+ }}
+ >
+
+ Feedback
+
+
+
+
+ );
+}
+
+export default FeedbackWidget;