tranth3truong Claude Fable 5 commited on
Commit
da09932
·
1 Parent(s): cc678b9

Deploy CP-UX-12/13 landing: hero draft proof, privacy and Vietnamese-capability evidence

Browse files
Files changed (38) hide show
  1. docs/TEST_MATRIX.md +2 -0
  2. docs/decisions/0015-soap-note-measurement-gate.md +2 -1
  3. docs/decisions/0017-in-house-testing-scope.md +40 -0
  4. docs/product/ai-scribe.md +4 -3
  5. docs/product/carepath-suite.md +9 -4
  6. docs/product/live-interpreter.md +3 -0
  7. docs/qa-evidence/README.md +20 -0
  8. docs/stories/CP-UX-09-interpreter-public-block.md +7 -2
  9. docs/stories/CP-UX-10-scribe-story-landing.md +69 -0
  10. docs/stories/CP-UX-11-problem-led-scribe-onboarding.md +68 -0
  11. docs/stories/CP-UX-12-landing-professional-polish.md +70 -0
  12. docs/stories/CP-UX-13-privacy-capability-visibility.md +63 -0
  13. docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/design.md +9 -7
  14. docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/execplan.md +11 -9
  15. docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/overview.md +8 -7
  16. docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/validation.md +6 -4
  17. docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/design.md +16 -13
  18. docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/execplan.md +12 -10
  19. docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/overview.md +7 -8
  20. docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/validation.md +13 -5
  21. docs/ux-redesign-carepath.md +425 -2
  22. scribe/frontend/index.html +2 -2
  23. scribe/frontend/src/App.test.tsx +14 -46
  24. scribe/frontend/src/App.tsx +6 -12
  25. scribe/frontend/src/LandingPage.test.tsx +72 -68
  26. scribe/frontend/src/LandingPage.tsx +279 -208
  27. scribe/frontend/src/LeadForm.test.tsx +25 -0
  28. scribe/frontend/src/LeadForm.tsx +23 -19
  29. scribe/frontend/src/content/strings.ts +302 -25
  30. scribe/frontend/src/scribe/ScribeShowcase.test.tsx +12 -32
  31. scribe/frontend/src/scribe/ScribeShowcase.tsx +61 -80
  32. scribe/frontend/src/scribe/ScribeTool.test.tsx +11 -28
  33. scribe/frontend/src/scribe/ScribeTool.tsx +12 -41
  34. scribe/frontend/src/styles.css +398 -493
  35. scribe/frontend/tests/demo-flow.spec.ts +102 -159
  36. scribe/training/scripts/validate_soap_ratings.py +3 -5
  37. scribe/training/soap_eval/README.md +16 -12
  38. scribe/training/tests/test_soap_ratings.py +5 -5
docs/TEST_MATRIX.md CHANGED
@@ -11,6 +11,8 @@ after the named evidence is actually run and recorded.
11
  | CP-BASE-004 | Interpreter console remains buildable and usable | yes | no | yes | no | implemented | lint; 38 unit; build; 4 browser tests passed (2026-07-12) |
12
  | GEC-001 | Offline GEC training uses ViMedCSS and synthetic pairs without human-labeling tooling | no | yes | no | no | implemented | Ruff; 26 focused GEC tests; root 95 passed, 1 skipped (2026-07-12) |
13
  | HARN-001 | Agent work follows durable CarePath intake and proof rules | no | yes | no | no | implemented | pinned merge install, CLI 0.1.11, init, matrix, audit, trace (2026-07-12) |
 
 
14
 
15
  Proof labels mean the relevant layer has evidence for the baseline; they do not
16
  claim every historical behavior was retested. Story packets define the exact
 
11
  | CP-BASE-004 | Interpreter console remains buildable and usable | yes | no | yes | no | implemented | lint; 38 unit; build; 4 browser tests passed (2026-07-12) |
12
  | GEC-001 | Offline GEC training uses ViMedCSS and synthetic pairs without human-labeling tooling | no | yes | no | no | implemented | Ruff; 26 focused GEC tests; root 95 passed, 1 skipped (2026-07-12) |
13
  | HARN-001 | Agent work follows durable CarePath intake and proof rules | no | yes | no | no | implemented | pinned merge install, CLI 0.1.11, init, matrix, audit, trace (2026-07-12) |
14
+ | CP-UX-10 | Public landing leads with Ghi chép bệnh án AI; Interpreter is visibly unavailable | yes | yes | yes | yes | implemented | lint; 46 unit; 5 deploy-env; build + NFC; 5 browser tests at 320–1440px; combined app 3 passed; Lighthouse 100/100/100 (2026-07-14) |
15
+ | CP-UX-11 | Vietnamese-only landing starts with the doctor's documentation burden and teaches the Scribe workflow before upload | yes | yes | yes | yes | implemented | lint; 45 unit; 5 deploy-env; build + NFC; 8 browser tests at 320–1440px with Axe/light/dark/reduced-motion; combined app 3 passed; Lighthouse 100/100/100 (2026-07-15) |
16
 
17
  Proof labels mean the relevant layer has evidence for the baseline; they do not
18
  claim every historical behavior was retested. Story packets define the exact
docs/decisions/0015-soap-note-measurement-gate.md CHANGED
@@ -4,7 +4,8 @@ Date: 2026-07-13
4
 
5
  ## Status
6
 
7
- Accepted
 
8
 
9
  ## Context
10
 
 
4
 
5
  ## Status
6
 
7
+ Superseded by [0017 In-House Testing Scope](0017-in-house-testing-scope.md)
8
+ on 2026-07-13.
9
 
10
  ## Context
11
 
docs/decisions/0017-in-house-testing-scope.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 0017 In-House Testing Scope
2
+
3
+ Date: 2026-07-13
4
+
5
+ ## Status
6
+
7
+ Accepted
8
+
9
+ ## Context
10
+
11
+ The restructure and public deployment work are complete. The remaining
12
+ 50-note clinician-rating target in decision 0015 would require an external
13
+ clinical-evidence program that is outside the current in-house testing scope.
14
+
15
+ ## Decision
16
+
17
+ Remove the 50-note clinician-rating target from the restructure completion
18
+ criteria and from the SOAP review utility. In-house review is optional and
19
+ informational: it may inspect synthetic or already-approved de-identified
20
+ material with the existing score-only rubric, but it cannot authorize a SOAP
21
+ fine-tuning, provider, or safety-policy change.
22
+
23
+ No audio, transcript, patient identifier, or note text may enter this
24
+ repository. A future clinical-evidence program or model decision requires a
25
+ separate owner decision.
26
+
27
+ ## Consequences
28
+
29
+ - The restructure can complete without external clinical-rating evidence.
30
+ - The rubric remains available for bounded in-house quality checks, without a
31
+ minimum sample-size goal or readiness result.
32
+ - CarePath makes no clinical-quality or model-readiness claim from in-house
33
+ testing.
34
+
35
+ ## Alternatives Considered
36
+
37
+ 1. Keep the 50-note gate: rejected because it conflicts with the current
38
+ owner-directed in-house scope.
39
+ 2. Delete the rubric and validator: rejected because they remain useful for
40
+ privacy-bounded internal QA.
docs/product/ai-scribe.md CHANGED
@@ -23,9 +23,10 @@ provides the workflow from `/ghi-chep-lam-sang/`.
23
  - The committed text-only frozen baseline report is verified in CI. Before an
24
  adapter can export, its frozen-fixture drug-name recall and dosage
25
  number/unit preservation must not regress against the raw-ASR baseline.
26
- - A SOAP fine-tuning decision is prohibited until the owner completes the
27
- clinician-rating protocol for at least 50 de-identified pilot notes in an
28
- approved environment. The rating schema stores no note text or audio here.
 
29
 
30
  ## Proof
31
 
 
23
  - The committed text-only frozen baseline report is verified in CI. Before an
24
  adapter can export, its frozen-fixture drug-name recall and dosage
25
  number/unit preservation must not regress against the raw-ASR baseline.
26
+ - In-house SOAP review may use only synthetic or already-approved
27
+ de-identified material. It is informational only and does not authorize a
28
+ SOAP fine-tuning, provider, or safety-policy change. The optional rating
29
+ schema stores no note text or audio here.
30
 
31
  ## Proof
32
 
docs/product/carepath-suite.md CHANGED
@@ -7,12 +7,17 @@ has two distinct workflows; the user must never need to know the English terms
7
  | Workflow | Purpose | Primary action |
8
  | --- | --- | --- |
9
  | Ghi chép bệnh án AI | AI hears a consultation and prepares structured clinical notes. | Bắt đầu ghi chép |
10
- | Phiên dịch khám bệnh trực tiếp | Translates between a Vietnamese-speaking clinician and an English-speaking patient during a consultation. | Bắt đầu phiên dịch |
11
 
12
- The landing page frames this choice as `Bạn muốn hỗ trợ việc hôm nay?`.
13
- Every clinical screen must say what workflow is active, what it helps with,
14
- what to do next, and its relevant limit or risk.
 
15
 
16
  CarePath remains clinician-controlled: Scribe drafts require review, and the
17
  Interpreter translates only. The detailed module contracts are
18
  `ai-scribe.md` and `live-interpreter.md`.
 
 
 
 
 
7
  | Workflow | Purpose | Primary action |
8
  | --- | --- | --- |
9
  | Ghi chép bệnh án AI | AI hears a consultation and prepares structured clinical notes. | Bắt đầu ghi chép |
10
+ | Phiên dịch khám bệnh trực tiếp | Translates between a Vietnamese-speaking clinician and an English-speaking patient during a consultation. | Đang phát triển chưa thể truy cập |
11
 
12
+ The current public landing leads with the Scribe job to be done and keeps the
13
+ Interpreter visible as unavailable. Every clinical screen must say what
14
+ workflow is active, what it helps with, what to do next, and its relevant limit
15
+ or risk.
16
 
17
  CarePath remains clinician-controlled: Scribe drafts require review, and the
18
  Interpreter translates only. The detailed module contracts are
19
  `ai-scribe.md` and `live-interpreter.md`.
20
+
21
+ The public web experience currently exposes Scribe as its only active product
22
+ journey. Interpreter remains visible as a non-interactive development status;
23
+ its browser routes stay closed until a separate release decision reopens them.
docs/product/live-interpreter.md CHANGED
@@ -18,6 +18,9 @@ Scribe is the only public product. `/phien-dich-y-khoa/*` and `/console/*`
18
  return 404; this does not change the Interpreter API, WebSocket, or safety
19
  invariants used for internal development.
20
 
 
 
 
21
  ## Safety Invariants
22
 
23
  - No microphone capture before recorded consent.
 
18
  return 404; this does not change the Interpreter API, WebSocket, or safety
19
  invariants used for internal development.
20
 
21
+ The Interpreter is on hold for public release. Restrict changes to internal
22
+ bug fixes and safety maintenance until the owner reopens its release work.
23
+
24
  ## Safety Invariants
25
 
26
  - No microphone capture before recorded consent.
docs/qa-evidence/README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Visual QA Evidence
2
+
3
+ This directory contains versioned visual proof for CarePath’s public site and
4
+ interpreter experience. These images are documentation, not application assets.
5
+
6
+ ## Coverage
7
+
8
+ - `scribe/frontend/` — landing and clinical-note workflow at desktop and mobile viewports.
9
+ - `interpreter/` — consent, quiz, device check, first-run, blocked-risk, and
10
+ forced-colors states across desktop and mobile viewports.
11
+
12
+ ## Retention
13
+
14
+ - Keep the complete evidence set in Git; it supports visual, accessibility, and
15
+ responsive-regression review.
16
+ - Refresh a scenario only after its corresponding browser or visual QA changes.
17
+ - Name new captures by surface, state, and viewport, matching the existing
18
+ convention (for example, `consent-checked-390x844.png`).
19
+ - Do not import images from this directory into runtime code or production
20
+ bundles.
docs/stories/CP-UX-09-interpreter-public-block.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  ## Status
4
 
5
- in progress
6
 
7
  ## Lane
8
 
@@ -47,4 +47,9 @@ non-runtime binary QA assets.
47
 
48
  ## Evidence
49
 
50
- Pending validation.
 
 
 
 
 
 
2
 
3
  ## Status
4
 
5
+ implemented
6
 
7
  ## Lane
8
 
 
47
 
48
  ## Evidence
49
 
50
+ 2026-07-13: combined-app route proof passed (3 tests); Scribe frontend unit
51
+ (45), build, and browser (7) checks passed; Ruff passed. Space commit
52
+ `cc678b9` is Running. Live HTTP checks returned 404 for
53
+ `/phien-dich-y-khoa/`, its asset path, and `/console/`; `/`,
54
+ `/ghi-chep-lam-sang/`, `/api/v1/health`, `/api/health`, and the Vercel public
55
+ site each returned 200.
docs/stories/CP-UX-10-scribe-story-landing.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CP-UX-10 Scribe-Led Public Landing Story
2
+
3
+ ## Status
4
+
5
+ implemented
6
+
7
+ ## Lane
8
+
9
+ normal
10
+
11
+ ## Product Contract
12
+
13
+ The public landing tells a Vietnamese-first story about reducing
14
+ post-consultation documentation work. Ghi chép bệnh án AI is the only active
15
+ journey. Phiên dịch khám bệnh trực tiếp is visibly in development and has no
16
+ public action.
17
+
18
+ ## Relevant Product Docs
19
+
20
+ - `docs/product/carepath-suite.md`
21
+ - `docs/product/ai-scribe.md`
22
+ - `docs/product/live-interpreter.md`
23
+ - `docs/ux-redesign-carepath.md`
24
+
25
+ ## Acceptance Criteria
26
+
27
+ - The Scribe hero, workflow, review limit, and canonical CTA are clear in
28
+ Vietnamese without requiring knowledge of the term Scribe.
29
+ - Interpreter status is prominent, non-interactive, and does not weaken its
30
+ existing 404 public-route contract.
31
+ - The Scribe pilot form cannot select or submit Interpreter interest.
32
+ - Mobile, keyboard, reduced-motion, diacritics, Axe, and Lighthouse gates pass.
33
+ - No backend, API, audio, consent, WebSocket, TTS, or risk behavior changes.
34
+
35
+ ## Design Notes
36
+
37
+ - Editorial split hero with a 72rem title width and interface-led product visual.
38
+ - Gapless 12-by-2 bento: `7×2 + 5×1 + 5×1 = 24` cells.
39
+ - Geist remains self-hosted. GSAP supplies desktop pinning and image scale/fade;
40
+ reduced-motion and mobile layouts remain static.
41
+ - No photography, fake testimonials, unsupported statistics, or remote assets.
42
+
43
+ ## Validation
44
+
45
+ | Layer | Expected proof |
46
+ | --- | --- |
47
+ | Unit | Landing copy, non-interactive status, Scribe CTA, Scribe-only form |
48
+ | E2E | Responsive layout, keyboard, Axe, reduced motion, Scribe route |
49
+ | Platform | Build, diacritics, Lighthouse, Vercel production and live routes |
50
+ | Regression | Combined-app Scribe 200 and Interpreter 404 route tests |
51
+
52
+ ## Harness Delta
53
+
54
+ Add this story to the local Harness, record the real proof results, and finish
55
+ with a trace. No new Harness capability is required.
56
+
57
+ ## Evidence
58
+
59
+ - Frontend lint passed; Vitest passed 46 tests across 10 files.
60
+ - Deploy-environment validation passed 5 tests; the production build and
61
+ Vietnamese NFC/diacritics gate passed.
62
+ - Playwright passed 5 flows across 320, 360, 390, 768, and 1440px, including
63
+ hero line limits, overflow, keyboard access, Axe, reduced motion, and the
64
+ canonical Scribe route.
65
+ - The combined FastAPI route proof passed 3 tests under the repository Python
66
+ 3.12 environment, including Scribe availability and Interpreter 404s.
67
+ - Lighthouse scored 100 performance, 100 accessibility, and 100 best practices.
68
+ - No interactive browser was connected for separate desktop/phone visual
69
+ capture, so that manual visual gate remains explicitly unverified.
docs/stories/CP-UX-11-problem-led-scribe-onboarding.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CP-UX-11 Problem-Led Scribe Onboarding
2
+
3
+ ## Status
4
+
5
+ implemented
6
+
7
+ ## Lane
8
+
9
+ normal
10
+
11
+ ## Product contract
12
+
13
+ The public site is Vietnamese-only and starts with the documentation burden a
14
+ doctor recognizes. It teaches the available Ghi chép bệnh án AI workflow with a
15
+ small self-calculator and a safe guided sample before asking the visitor to
16
+ upload audio or contact the pilot team.
17
+
18
+ Phiên dịch khám bệnh trực tiếp remains a non-interactive development status.
19
+
20
+ ## Scope
21
+
22
+ - Compact the public landing page and remove the VI/EN control.
23
+ - Add a four-question, browser-only documentation-time calculator.
24
+ - Reuse the existing Scribe showcase for a guided sample that never invents an
25
+ assessment or plan.
26
+ - Put upload instructions and the upload control on the same Scribe tool screen.
27
+ - Keep the pilot form Scribe-only and secondary to the working product action.
28
+
29
+ No backend, API, audio processing, consent, retention, Interpreter, WebSocket,
30
+ TTS, or clinical safety behavior changes.
31
+
32
+ ## Acceptance criteria
33
+
34
+ - The hero says `Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.`
35
+ and its first action opens the guided sample.
36
+ - The site contains no public English-language toggle or English-first product
37
+ label.
38
+ - A visitor can calculate current daily documentation time without a CarePath
39
+ savings claim or network request.
40
+ - The sample explains conversation, clarified transcript, structured draft, and
41
+ clinician review; Assessment and Plan remain for the doctor to complete.
42
+ - The real Scribe route shows instructions and the audio upload control together
43
+ without an extra continue gate.
44
+ - Interpreter status remains visible, non-interactive, and its public routes
45
+ remain 404.
46
+ - Mobile, keyboard, reduced-motion, diacritics, Axe, build, Lighthouse, and
47
+ combined-app checks pass.
48
+
49
+ ## Validation
50
+
51
+ ```powershell
52
+ cd scribe\frontend
53
+ npm.cmd run lint
54
+ npm.cmd test
55
+ npm.cmd run test:deploy-env
56
+ npm.cmd run build
57
+ npm.cmd run e2e
58
+ npm.cmd run lighthouse
59
+ cd ..\..
60
+ python -m pytest scribe/tests/test_combined_app.py
61
+ ```
62
+
63
+ ## Proof
64
+
65
+ - unit: 45 passed
66
+ - integration: combined app 3 passed
67
+ - e2e: 8 passed at 320, 360, 390, 768, and 1440px; phone and desktop screenshots captured
68
+ - platform: lint, deploy-env 5 passed, build/NFC passed, Lighthouse 100/100/100
docs/stories/CP-UX-12-landing-professional-polish.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CP-UX-12 Professional Landing Polish
2
+
3
+ ## Status
4
+
5
+ implemented
6
+
7
+ ## Lane
8
+
9
+ normal
10
+
11
+ ## Product contract
12
+
13
+ The public landing keeps the CP-UX-11 problem-led structure and copy contract,
14
+ and presents it with professional polish: the hero shows a conversation-to-draft
15
+ proof of the product, the Interpreter development status is visible but quiet,
16
+ the page has one dark closing moment instead of three, and the footer stays
17
+ readable at phone widths.
18
+
19
+ ## Scope
20
+
21
+ - Replace the hero daily-flow table with a static conversation-to-draft proof
22
+ panel reusing the sanitized sample; Subjective and Objective rows only.
23
+ - Keep both hero actions on one row at desktop widths.
24
+ - Restyle the Interpreter status as a bordered notice; same element, same
25
+ strings, still non-interactive.
26
+ - One shared section-padding rhythm; trust section light; start section is the
27
+ only dark section before the footer.
28
+ - Root-cause the footer responsive bug (late `.site-footer` override) and stack
29
+ the footer at phone widths.
30
+
31
+ No route, API, form, showcase-interaction, Scribe-tool, consent, audio,
32
+ Interpreter, or safety behavior changes. No new dependency.
33
+
34
+ ## Acceptance criteria
35
+
36
+ - Hero shows the proof panel labeled as a cleaned sample; `Chờ bác sĩ nhập
37
+ hoặc xác nhận.` still never renders before the doctor opens the draft step.
38
+ - Both hero actions render on one row at 1024 px and wider.
39
+ - Interpreter status keeps exact strings, zero links or buttons, and loses the
40
+ dark band treatment.
41
+ - Exactly one dark section between the evidence grid and the footer.
42
+ - Footer stacks into readable rows at 320, 360, and 390 px.
43
+ - Hero under 800 px at 1440×900; H1 line limits hold; no horizontal overflow
44
+ from 320 to 1440 px.
45
+ - Lint, unit, deploy-env, build with diacritics gate, Playwright, Lighthouse,
46
+ and combined-app checks pass.
47
+
48
+ ## Validation
49
+
50
+ ```powershell
51
+ cd scribe\frontend
52
+ npm.cmd run lint
53
+ npm.cmd test
54
+ npm.cmd run test:deploy-env
55
+ npm.cmd run build
56
+ npm.cmd run e2e
57
+ npm.cmd run lighthouse
58
+ cd ..\..
59
+ python -m pytest scribe/tests/test_combined_app.py
60
+ ```
61
+
62
+ ## Evidence
63
+
64
+ 2026-07-15: lint passed; 46 unit tests passed; 5 deploy-env tests passed;
65
+ production build and NFC diacritics gate passed; 8 Playwright tests passed
66
+ (keyboard, responsive 320–1440, axe light and dark, touch menu, reduced
67
+ motion); combined-app 3 passed; Lighthouse 100/100/100 with CLS 0.
68
+ Desktop and mobile screenshots verified: hero proof panel above the fold,
69
+ single-row hero actions, quiet Interpreter notice, one dark closing section,
70
+ stacked phone footer.
docs/stories/CP-UX-13-privacy-capability-visibility.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CP-UX-13 Privacy and Vietnamese-Capability Visibility
2
+
3
+ ## Status
4
+
5
+ implemented
6
+
7
+ ## Lane
8
+
9
+ normal
10
+
11
+ Privacy hard-gate note: this story restates verified, already-shipped privacy
12
+ claims more prominently. It changes no retention, consent, microphone, or API
13
+ behavior. Every claim is grounded in `scribe/carepath/main.py` (request-scoped
14
+ `TemporaryDirectory` processing) and `docs/product/ai-scribe.md` (temporary
15
+ working storage only; consultation audio is never training data).
16
+
17
+ ## Product contract
18
+
19
+ The landing makes its trust facts visible instead of buried: the hero states
20
+ that audio is not retained, and the trust section shows two evidence panels,
21
+ Vietnamese capability and audio privacy, under one heading that keeps the
22
+ clinician-review requirement and the no-advice boundary. Review is framed as
23
+ fast and targeted because corrections and gaps are marked, never as optional.
24
+
25
+ ## Scope
26
+
27
+ - Extend the hero small print with the audio-retention fact.
28
+ - Replace the four-bullet trust list with capability and privacy panels,
29
+ absorbing all previous safety statements.
30
+ - No accuracy figure, no savings promise, no suggestion to skip review.
31
+ - No route, API, backend, form, or Scribe-tool changes. No new dependency.
32
+
33
+ ## Acceptance criteria
34
+
35
+ - Audio-retention fact visible in the hero without scrolling.
36
+ - Trust section shows both panels with all six verified claims plus the
37
+ review requirement and no-advice boundary.
38
+ - No prior safety statement is lost from the page.
39
+ - CP-UX-12 gates still pass: lint, unit, deploy-env, build with diacritics,
40
+ Playwright with axe light and dark, Lighthouse, combined-app tests.
41
+
42
+ ## Validation
43
+
44
+ ```powershell
45
+ cd scribe\frontend
46
+ npm.cmd run lint
47
+ npm.cmd test
48
+ npm.cmd run test:deploy-env
49
+ npm.cmd run build
50
+ npm.cmd run e2e
51
+ npm.cmd run lighthouse
52
+ cd ..\..
53
+ python -m pytest scribe/tests/test_combined_app.py
54
+ ```
55
+
56
+ ## Evidence
57
+
58
+ 2026-07-15: lint passed; 47 unit tests passed; 5 deploy-env tests passed;
59
+ production build and NFC diacritics gate passed; 8 Playwright tests passed
60
+ (axe light and dark, responsive 320–1440, reduced motion); combined-app 3
61
+ passed; Lighthouse 100/100/100 with CLS 0. Screenshots verified the two
62
+ trust panels, extended hero small print, and intact review and no-advice
63
+ copy at desktop and mobile widths.
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/design.md CHANGED
@@ -8,10 +8,10 @@ contains no clinical source material.
8
 
9
  ## Application Flow
10
 
11
- The owner exports anonymous score-only rows from the approved review
12
- environment and runs `validate_soap_ratings.py` locally. The summary reports
13
- the number of unique notes, mean scores, serious hallucinations, unsafe
14
- dispositions, and whether the 50-note decision threshold is met.
15
 
16
  ## Data Model
17
 
@@ -25,8 +25,10 @@ The CLI prints a JSON summary only; it never writes, logs, or uploads ratings.
25
 
26
  ## Alternatives Considered
27
 
28
- 1. Use synthetic note ratings.
 
29
  2. Store full notes and audio beside the code.
30
- 3. Fine-tune before clinician measurement.
31
 
32
- All are rejected because they would create weak or unsafe evidence.
 
 
8
 
9
  ## Application Flow
10
 
11
+ An in-house reviewer exports anonymous score-only rows from an approved local
12
+ review environment and runs `validate_soap_ratings.py` locally. The summary
13
+ reports the number of unique notes, mean scores, serious hallucinations, and
14
+ unsafe dispositions. It has no readiness result or decision threshold.
15
 
16
  ## Data Model
17
 
 
25
 
26
  ## Alternatives Considered
27
 
28
+ 1. Use synthetic or already-approved de-identified material for in-house
29
+ testing.
30
  2. Store full notes and audio beside the code.
31
+ 3. Change the model based on in-house measurements alone.
32
 
33
+ The first option is accepted for bounded in-house testing. The latter options
34
+ are rejected because they would create privacy or safety risk.
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/execplan.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  ## Goal
4
 
5
- Prepare an owner-led, de-identified measurement gate for SOAP quality.
6
 
7
  ## Scope
8
 
@@ -10,32 +10,34 @@ In scope:
10
 
11
  - Rubric and blank score-only schema.
12
  - Standard-library validation and summary.
13
- - Product contract that prevents premature fine-tuning.
14
 
15
  Out of scope:
16
 
17
- - Clinical-data collection, storage, rating, or model changes.
18
 
19
  ## Risk Classification
20
 
21
  Risk flags:
22
 
23
- - Clinical data and model-quality decision policy.
24
 
25
  Hard gates:
26
 
27
  - No source material enters the repository.
28
- - At least 50 distinct notes must be rated before the owner decides whether to
29
- change the model.
 
30
 
31
  ## Work Phases
32
 
33
  1. Define the minimum score-only schema and rubric.
34
- 2. Validate bounded scores and the unique-note threshold.
35
- 3. Document owner prerequisites and stop conditions.
36
  4. Prove the validator with synthetic in-memory test rows only.
37
 
38
  ## Stop Conditions
39
 
40
  Pause for owner direction if any request would add patient data, audio,
41
- transcripts, or note text to this repository.
 
 
2
 
3
  ## Goal
4
 
5
+ Provide an optional, privacy-bounded in-house SOAP quality review tool.
6
 
7
  ## Scope
8
 
 
10
 
11
  - Rubric and blank score-only schema.
12
  - Standard-library validation and summary.
13
+ - Product contract that keeps in-house results informational.
14
 
15
  Out of scope:
16
 
17
+ - Clinical-data collection or storage in this repository, or model changes.
18
 
19
  ## Risk Classification
20
 
21
  Risk flags:
22
 
23
+ - Clinical data, privacy, and model-quality decision policy.
24
 
25
  Hard gates:
26
 
27
  - No source material enters the repository.
28
+ - The summary has no minimum sample-size or model-readiness result.
29
+ - No model, provider, or safety-policy change follows from in-house results
30
+ without a separate owner decision.
31
 
32
  ## Work Phases
33
 
34
  1. Define the minimum score-only schema and rubric.
35
+ 2. Validate bounded scores and summarize anonymous review metadata.
36
+ 3. Document the in-house scope and stop conditions.
37
  4. Prove the validator with synthetic in-memory test rows only.
38
 
39
  ## Stop Conditions
40
 
41
  Pause for owner direction if any request would add patient data, audio,
42
+ transcripts, or note text to this repository, or use in-house results to
43
+ change a model, provider, or safety policy.
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/overview.md CHANGED
@@ -2,18 +2,18 @@
2
 
3
  ## Current Behavior
4
 
5
- The Scribe produces a clinician-reviewed SOAP draft, but no durable protocol
6
- exists for assessing its quality before changing the model.
7
 
8
  ## Target Behavior
9
 
10
- The repository provides a de-identified rubric, blank schema, and validator.
11
- The owner can collect at least 50 real clinician ratings in an approved
12
- environment before making a tuning decision.
13
 
14
  ## Affected Users
15
 
16
- - Clinical owner and clinician reviewers.
17
 
18
  ## Affected Product Docs
19
 
@@ -21,5 +21,6 @@ environment before making a tuning decision.
21
 
22
  ## Non-Goals
23
 
24
- - Collect pilot audio, patient data, generated note text, or ratings.
 
25
  - Train, fine-tune, or change the SOAP serving provider.
 
2
 
3
  ## Current Behavior
4
 
5
+ The Scribe produces a clinician-reviewed SOAP draft. The repository now has a
6
+ bounded review tool, but current work is limited to in-house testing.
7
 
8
  ## Target Behavior
9
 
10
+ The repository provides an optional score-only rubric, blank schema, and
11
+ validator for informational in-house review. It has no minimum sample-size
12
+ target and cannot authorize a model change.
13
 
14
  ## Affected Users
15
 
16
+ - In-house reviewers.
17
 
18
  ## Affected Product Docs
19
 
 
21
 
22
  ## Non-Goals
23
 
24
+ - Collect source audio, patient data, generated note text, or ratings in this
25
+ repository.
26
  - Train, fine-tune, or change the SOAP serving provider.
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/validation.md CHANGED
@@ -9,7 +9,7 @@ is created or persisted.
9
 
10
  | Layer | Cases |
11
  | --- | --- |
12
- | Unit | 50-note threshold, serious-hallucination summary, invalid-score rejection |
13
  | Integration | Existing CPU-only training test job discovers the validator test |
14
  | E2E | Not applicable; no product surface changes |
15
  | Platform | No external provider required |
@@ -23,6 +23,8 @@ python -m ruff check scribe/training/scripts/validate_soap_ratings.py scribe/tra
23
 
24
  ## Acceptance Evidence
25
 
26
- 2026-07-13: focused proof passed with `38 passed`; no data collection, model
27
- training, or serving behavior change occurred. The owner has not yet supplied
28
- the required pilot data or clinical ratings, so no model decision is claimed.
 
 
 
9
 
10
  | Layer | Cases |
11
  | --- | --- |
12
+ | Unit | Quality summary, serious-hallucination summary, invalid-score rejection |
13
  | Integration | Existing CPU-only training test job discovers the validator test |
14
  | E2E | Not applicable; no product surface changes |
15
  | Platform | No external provider required |
 
23
 
24
  ## Acceptance Evidence
25
 
26
+ 2026-07-13: `python -m pytest scribe/training/tests` passed with `54 passed,
27
+ 1 skipped`; the review-validator focused test passed with `4 passed`, and
28
+ Ruff passed. No data collection, model training, or serving behavior change
29
+ occurred. The tool is limited to optional in-house testing and makes no model
30
+ decision claim.
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/design.md CHANGED
@@ -2,26 +2,27 @@
2
 
3
  ## Domain Model
4
 
5
- No repository model or product contract changes are required. Deployment
6
- credentials and health-data consent remain owner-controlled trust boundaries.
 
7
 
8
  ## Application Flow
9
 
10
  The deployment owner configures Vercel exactly as `docs/deploy.md` specifies,
11
- then validates the live site and Space health endpoints. Separately, the
12
- clinical owner obtains approved, de-identified pilot material and at least 50
13
- clinician rubric ratings before any SOAP model decision.
14
 
15
  ## Interface Contract
16
 
17
  The existing Vercel environment validation and Space health endpoints are the
18
- deployment contract. The score-only SOAP rating schema and validator are the
19
- clinical-measurement contract.
20
 
21
  ## Data Model
22
 
23
- No clinical material belongs in this repository. Only anonymous score metadata
24
- that passes the existing validator may be processed in an approved environment.
25
 
26
  ## UI / Platform Impact
27
 
@@ -29,10 +30,12 @@ No local UI change. The Vercel Root Directory must point to `scribe/frontend`.
29
 
30
  ## Observability
31
 
32
- Capture live deployment status and owner-approved rating summaries outside this
33
- repository's source tree.
34
 
35
  ## Alternatives Considered
36
 
37
- 1. Simulate deployment or clinical evidence: rejected because it would not
38
- prove the external environment or satisfy clinical-data safeguards.
 
 
 
2
 
3
  ## Domain Model
4
 
5
+ No runtime change is required. The product contract documents that optional
6
+ in-house review is informational only; deployment credentials and health-data
7
+ consent remain owner-controlled trust boundaries.
8
 
9
  ## Application Flow
10
 
11
  The deployment owner configures Vercel exactly as `docs/deploy.md` specifies,
12
+ then validates the live site and Space health endpoints. Optional in-house
13
+ review may use synthetic or already-approved de-identified material, but it
14
+ does not authorize any SOAP model decision.
15
 
16
  ## Interface Contract
17
 
18
  The existing Vercel environment validation and Space health endpoints are the
19
+ deployment contract. The score-only SOAP review schema and validator provide
20
+ in-house quality signals only.
21
 
22
  ## Data Model
23
 
24
+ No clinical material belongs in this repository. Optional review metadata may
25
+ be processed only outside the source tree using the existing validator.
26
 
27
  ## UI / Platform Impact
28
 
 
30
 
31
  ## Observability
32
 
33
+ Capture live deployment status. Optional in-house review summaries stay outside
34
+ this repository's source tree.
35
 
36
  ## Alternatives Considered
37
 
38
+ 1. Keep the external clinical-rating gate: rejected because it conflicts with
39
+ the owner-directed in-house testing scope.
40
+ 2. Simulate live deployment: rejected because it would not prove the external
41
+ environment.
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/execplan.md CHANGED
@@ -2,8 +2,8 @@
2
 
3
  ## Goal
4
 
5
- Complete the owner-only end conditions without weakening deployment or clinical
6
- safety requirements.
7
 
8
  ## Scope
9
 
@@ -12,32 +12,34 @@ In scope:
12
  - Owner configures Vercel Root Directory as `scribe/frontend` and supplies the
13
  documented deployment environment variables.
14
  - Owner rebuilds the Docker Space and verifies the documented live routes.
15
- - Clinical owner completes the approved de-identified rating protocol.
 
16
 
17
  Out of scope:
18
 
19
  - Repository-side deployment automation or clinical-data collection.
20
- - Any model training or SOAP provider change before the rating threshold.
21
 
22
  ## Risk Classification
23
 
24
  Risk flags:
25
 
26
- - Deployment, credentials, clinical data, privacy.
27
 
28
  Hard gates:
29
 
30
  - No source audio, transcript, identifier, or note text enters the repository.
31
- - No SOAP model decision before 50 approved clinician ratings.
 
32
 
33
  ## Work Phases
34
 
35
  1. Owner completes the Vercel and Space configuration in `docs/deploy.md`.
36
  2. Owner records live route and health-endpoint evidence.
37
- 3. Clinical owner completes legal approval, de-identification, and ratings.
38
- 4. Owner reopens the plan with that evidence for final verification.
39
 
40
  ## Stop Conditions
41
 
42
- Do not proceed without deployment-owner access or approved clinical-data
43
- authority.
 
2
 
3
  ## Goal
4
 
5
+ Record the completed owner deployment work and replace the external
6
+ clinician-rating completion gate with an in-house testing scope.
7
 
8
  ## Scope
9
 
 
12
  - Owner configures Vercel Root Directory as `scribe/frontend` and supplies the
13
  documented deployment environment variables.
14
  - Owner rebuilds the Docker Space and verifies the documented live routes.
15
+ - Product and plan records state that optional in-house SOAP review is
16
+ informational only.
17
 
18
  Out of scope:
19
 
20
  - Repository-side deployment automation or clinical-data collection.
21
+ - Any model training or SOAP provider change from in-house review results.
22
 
23
  ## Risk Classification
24
 
25
  Risk flags:
26
 
27
+ - Deployment, credentials, clinical data, privacy, model-decision policy.
28
 
29
  Hard gates:
30
 
31
  - No source audio, transcript, identifier, or note text enters the repository.
32
+ - No SOAP model, provider, or safety-policy change from in-house review results
33
+ without a separate owner decision.
34
 
35
  ## Work Phases
36
 
37
  1. Owner completes the Vercel and Space configuration in `docs/deploy.md`.
38
  2. Owner records live route and health-endpoint evidence.
39
+ 3. Record the deliberate in-house-only scope and its safety limits.
40
+ 4. Verify the policy, plan, optional review utility, and Harness evidence.
41
 
42
  ## Stop Conditions
43
 
44
+ Pause for owner direction if a request would collect clinical data or make a
45
+ model, provider, or safety-policy change from in-house review results.
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/overview.md CHANGED
@@ -2,19 +2,18 @@
2
 
3
  ## Current Behavior
4
 
5
- The repository restructure and local product matrix are complete. Two final
6
- conditions cannot be performed from the repository: changing Vercel/Space
7
- settings and collecting legally approved clinical evidence.
8
 
9
  ## Target Behavior
10
 
11
- The owner completes the documented deployment settings and the approved,
12
- de-identified clinician-rating process before calling the whole plan complete.
13
 
14
  ## Affected Users
15
 
16
  - Deployment owner.
17
- - Clinical owner and clinician reviewers.
18
 
19
  ## Affected Product Docs
20
 
@@ -23,5 +22,5 @@ de-identified clinician-rating process before calling the whole plan complete.
23
 
24
  ## Non-Goals
25
 
26
- - Collect audio, transcripts, patient data, or clinician ratings in this repo.
27
- - Change Vercel, Hugging Face, or cloud-provider settings without owner access.
 
2
 
3
  ## Current Behavior
4
 
5
+ The repository restructure and local product matrix are complete. The owner
6
+ has completed the deployment configuration and live-route verification.
 
7
 
8
  ## Target Behavior
9
 
10
+ The owner records the current in-house testing scope. No external
11
+ clinician-rating target blocks completion of the restructure.
12
 
13
  ## Affected Users
14
 
15
  - Deployment owner.
16
+ - In-house reviewers.
17
 
18
  ## Affected Product Docs
19
 
 
22
 
23
  ## Non-Goals
24
 
25
+ - Collect audio, transcripts, patient data, or review data in this repo.
26
+ - Change a model, provider, or safety policy from in-house review results.
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/validation.md CHANGED
@@ -13,7 +13,7 @@ not establish a live deployment or authorize clinical data processing.
13
  | Integration | Term-artifact drift, training baseline, mock Scribe smoke |
14
  | E2E | Both Vite frontends' Playwright suites |
15
  | Platform | Owner verifies Vercel and Space routes after configuration |
16
- | Clinical | Owner supplies approved, de-identified rubric summary |
17
 
18
  ## Commands
19
 
@@ -58,7 +58,15 @@ The rebuilt Space is Running at
58
  Interpreter product link returns to the canonical Vercel site with language
59
  preserved.
60
 
61
- Pending owner evidence: the approved 50-note clinician-rating threshold
62
- reached without storing clinical material in the repository. No container
63
- provider is registered in Harness, so that clinical evidence cannot be
64
- collected by this workspace.
 
 
 
 
 
 
 
 
 
13
  | Integration | Term-artifact drift, training baseline, mock Scribe smoke |
14
  | E2E | Both Vite frontends' Playwright suites |
15
  | Platform | Owner verifies Vercel and Space routes after configuration |
16
+ | Clinical | Not required for the current in-house-only scope |
17
 
18
  ## Commands
19
 
 
58
  Interpreter product link returns to the canonical Vercel site with language
59
  preserved.
60
 
61
+ 2026-07-13 public availability update: Space commit `cc678b9` is Running.
62
+ `/phien-dich-y-khoa/`, its asset path, and `/console/` now return HTTP 404;
63
+ the Space root, `/ghi-chep-lam-sang/`, `/api/v1/health`, and `/api/health`
64
+ continue to return HTTP 200. The public Scribe site on Vercel also returns 200.
65
+
66
+ 2026-07-13 in-house scope evidence: owner direction removed the external
67
+ 50-note clinician-rating target. Decision 0017, the product contract, the
68
+ restructure plan, and the optional review utility now make in-house testing
69
+ informational only. No clinical material is collected or stored in this
70
+ repository, and no model decision is claimed. The focused review-validator
71
+ test passed with `4 passed`; the full training slice passed with `54 passed,
72
+ 1 skipped`; and Ruff passed.
docs/ux-redesign-carepath.md CHANGED
@@ -1,12 +1,434 @@
1
  # CarePath Vietnamese-first UX implementation stories
2
 
3
- **Status:** implementation backlog only. No application code is implemented by this document.
4
 
5
  **Objective:** make CarePath Vietnamese-first and make its two products unmistakably different:
6
 
7
  1. **CarePath Trợ lý ghi chép lâm sàng** — creates a clinician-reviewed draft from uploaded Vietnamese visit audio.
8
  2. **CarePath Phiên dịch y khoa Việt–Anh** — supports turn-by-turn conversation and blocks risky output until clinician confirmation.
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ## Backlog rules
11
 
12
  - Implement one story per commit or pull request.
@@ -34,7 +456,8 @@ that URL can still open the unfinished workflow.
34
  2. Every browser request to `/phien-dich-y-khoa/*` and `/console/*` returns
35
  HTTP 404 instead of the Interpreter frontend.
36
  3. Interpreter APIs, WebSockets, backend safety behavior, and automated tests
37
- remain available for internal development; no public page links to them.
 
38
 
39
  ### Affected routes, pages, and components
40
 
 
1
  # CarePath Vietnamese-first UX implementation stories
2
 
3
+ **Status:** active UX implementation contract and backlog.
4
 
5
  **Objective:** make CarePath Vietnamese-first and make its two products unmistakably different:
6
 
7
  1. **CarePath Trợ lý ghi chép lâm sàng** — creates a clinician-reviewed draft from uploaded Vietnamese visit audio.
8
  2. **CarePath Phiên dịch y khoa Việt–Anh** — supports turn-by-turn conversation and blocks risky output until clinician confirmation.
9
 
10
+ ## Current priority update: Scribe-led public landing
11
+
12
+ ### Current UX problem
13
+
14
+ The public landing technically labels the Interpreter as being in development,
15
+ but still presents it as a peer product choice and sends its card to the pilot
16
+ form. That affordance competes with the only available web workflow and leaves
17
+ the Scribe value story fragmented across unrelated product and safety sections.
18
+
19
+ ### Proposed flow
20
+
21
+ 1. Show a prominent, non-interactive status rail stating that **Phiên dịch khám
22
+ bệnh trực tiếp** is in development and cannot currently be accessed on the
23
+ web.
24
+ 2. Lead with the documentation burden after a consultation, then show how an
25
+ approved audio upload becomes a clinician-reviewed structured draft.
26
+ 3. Keep **Bắt đầu ghi chép** as the only product-launch action.
27
+ 4. Keep the pilot form as a secondary Scribe-only contact path.
28
+
29
+ ### Affected routes, pages, and components
30
+
31
+ - `/` in `scribe/frontend/src/LandingPage.tsx`
32
+ - Landing copy and metadata in `scribe/frontend/src/content/strings.ts`
33
+ - Landing presentation and reduced-motion behavior in `scribe/frontend/src/styles.css`
34
+ - The reusable pilot form's optional product-interest field
35
+ - `/ghi-chep-lam-sang/` remains unchanged
36
+
37
+ ### Vietnamese-first copy
38
+
39
+ - Interpreter status: `Đang phát triển — hiện chưa thể truy cập trên web.`
40
+ - Hero: `Tập trung vào người bệnh. Để CarePath soạn bản nháp sau buổi khám.`
41
+ - Hero detail: `Tải lên tệp âm thanh buổi khám đã được phép sử dụng. CarePath
42
+ chép lời, chuẩn hóa thuật ngữ và tạo bản nháp bệnh án có cấu trúc để bác sĩ
43
+ kiểm tra.`
44
+ - Primary action: `Bắt đầu ghi chép`
45
+ - Secondary action: `Xem cách hoạt động`
46
+ - Problem chapter: `Một buổi khám không nên biến thành hai ca làm việc.`
47
+ - Safety limit: `Bản nháp chỉ được dùng sau khi bác sĩ kiểm tra.`
48
+ - Final action: `Bắt đầu từ một tệp âm thanh đã được phép sử dụng.`
49
+
50
+ ### Implementation story: CP-UX-10
51
+
52
+ - Replace the two-product decision accordion with a Scribe-led AIDA narrative.
53
+ - Use the existing Geist font, CarePath mark, sanitized Scribe sample, and
54
+ existing form; add only GSAP and its React adapter for the approved motion.
55
+ - Add a gapless 12-column problem bento, native horizontal workflow accordion,
56
+ decorative process marquee, and desktop-only pinned workflow visuals.
57
+ - Lock the landing pilot form to Scribe and hide its product-interest selector.
58
+ - Remove landing-only Interpreter evidence and obsolete selector rules.
59
+
60
+ ### Dependencies
61
+
62
+ - CP-UX-09 keeps the Interpreter browser routes blocked.
63
+ - CP-BASE-003 supplies the existing public-site test and deployment gates.
64
+ - The Vercel project continues to build from `scribe/frontend` on `main`.
65
+
66
+ ### Acceptance criteria
67
+
68
+ - Scribe is the only active product journey and its CTA opens
69
+ `/ghi-chep-lam-sang/`.
70
+ - Interpreter status is visible near the top of the page and contains no link
71
+ or button.
72
+ - The page explains the post-consultation documentation problem, the Scribe
73
+ workflow, and mandatory clinician review without unsupported claims.
74
+ - Vietnamese and English layouts work at 320, 360, 390, 768, and 1440 pixels
75
+ without horizontal overflow.
76
+ - Keyboard navigation, reduced motion, diacritics, and serious Axe checks pass.
77
+ - Existing Scribe functionality works and Interpreter browser routes remain 404.
78
+
79
+ ### Validation commands
80
+
81
+ ```powershell
82
+ cd scribe\frontend
83
+ npm.cmd run lint
84
+ npm.cmd test
85
+ npm.cmd run test:deploy-env
86
+ npm.cmd run build
87
+ npm.cmd run e2e
88
+ npm.cmd run lighthouse
89
+ cd ..\..
90
+ python -m pytest scribe/tests/test_combined_app.py
91
+ ```
92
+
93
+ ### Risks and fallback behavior
94
+
95
+ - GSAP runs only on desktop when reduced motion is not requested; without it,
96
+ all content remains in normal document flow.
97
+ - The marquee is decorative and stops for reduced motion. Its meaning is also
98
+ available as static text.
99
+ - No Interpreter route, API, WebSocket, consent, microphone, TTS, or risk logic
100
+ changes. If the status presentation fails, it remains plain static text and
101
+ never becomes a launch action.
102
+
103
+ ## Current priority update: problem-led Vietnamese Scribe onboarding
104
+
105
+ ### Current UX problem
106
+
107
+ The Scribe-led landing explains the product before it earns attention from the
108
+ doctor's daily problem. Large decorative panels, 8–14rem section gaps, a 31rem
109
+ hero visual, and a 29rem accordion create long stretches with little actionable
110
+ information. The working Scribe route then adds another obstacle: visitors must
111
+ read a separate pre-start screen and click continue before they can even see the
112
+ upload control. The VI/EN switch also adds a choice that is not needed for this
113
+ Vietnamese-first release.
114
+
115
+ ### Proposed flow
116
+
117
+ 1. Keep the non-interactive Interpreter development status near the top.
118
+ 2. Lead with `Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.`
119
+ 3. Let doctors recognize their day and calculate their current documentation
120
+ time from four short inputs. Show current burden only, never a promised
121
+ CarePath saving.
122
+ 4. Reuse one guided sample to show conversation → clarified transcript →
123
+ structured draft → doctor review.
124
+ 5. Show evidence in three honest levels: the dated Vietnamese workload norm,
125
+ international ambient-scribe evidence, and what CarePath still needs to
126
+ prove locally.
127
+ 6. Put trust limits before the action, then offer the working Scribe tool first
128
+ and the organization pilot form as a secondary disclosure.
129
+ 7. On the Scribe route, show brief instructions and the upload control together.
130
+
131
+ ### Affected routes, pages, and components
132
+
133
+ - `/` in `scribe/frontend/src/LandingPage.tsx`
134
+ - `/ghi-chep-lam-sang/` in `scribe/frontend/src/scribe/ScribeTool.tsx`
135
+ - `scribe/frontend/src/scribe/ScribeShowcase.tsx`
136
+ - Vietnamese landing, sample, metadata, and Scribe-tool copy
137
+ - Focused unit, Playwright, stylesheet, package, and Harness proof files
138
+
139
+ ### Vietnamese-first copy
140
+
141
+ - Hero: `Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.`
142
+ - Hero detail: `CarePath chuẩn bị ghi chú lâm sàng từ cuộc trao đổi để bác sĩ
143
+ kiểm tra và xác nhận.`
144
+ - Primary action: `Trải nghiệm ca khám mẫu`
145
+ - Secondary action: `Tính thời gian ghi chép`
146
+ - Calculator result: current daily documentation time only, plus a clearly
147
+ labelled five-day equivalent.
148
+ - Sample limit: `Nhận định` and `Kế hoạch` display
149
+ `Chờ bác sĩ nhập hoặc xác nhận.`
150
+ - Tool helper: `Xem ca khám mẫu` links back to `/#demo`.
151
+
152
+ ### Implementation story: CP-UX-11
153
+
154
+ - Delete the decorative marquee, bento, horizontal accordion, pinned gallery,
155
+ language toggle, and GSAP dependencies.
156
+ - Use semantic HTML, the existing Geist font, existing logo, existing lead form,
157
+ and the existing Scribe showcase; add no dependency or remote asset.
158
+ - Add the calculator as local React state with the transparent formula
159
+ `số bệnh nhân × phút ghi chép mỗi bệnh nhân`.
160
+ - Keep English copy data that other frozen demo code may still consume, but the
161
+ public App always renders Vietnamese and exposes no language control.
162
+ - Remove the Scribe tool's `readyToRecord` gate without changing validation,
163
+ upload, progress, error, API, or result behavior.
164
+
165
+ ### Dependencies
166
+
167
+ - CP-UX-10 supplies the current Scribe-only public route and Scribe-only form.
168
+ - CP-UX-09 keeps both Interpreter browser paths at 404.
169
+ - CP-BASE-003 supplies deployment, accessibility, and responsive test gates.
170
+
171
+ ### Acceptance criteria
172
+
173
+ - The site starts with the doctor's documentation problem, not a definition of
174
+ AI Medical Scribe.
175
+ - There is no public EN toggle; all visible onboarding and tool copy is
176
+ Vietnamese.
177
+ - The calculator works without storage, analytics, network requests, or a
178
+ CarePath productivity prediction.
179
+ - The guided sample teaches the workflow and leaves diagnosis and treatment
180
+ decisions to the doctor.
181
+ - The upload control is visible on first entry to the Scribe tool.
182
+ - The landing has no horizontal overflow at 320, 360, 390, 768, and 1440px and
183
+ no oversized empty instructional panel.
184
+ - Keyboard, Axe, reduced-motion, dark/light, diacritics, Scribe-route, lead,
185
+ Lighthouse, and Interpreter-404 checks pass.
186
+
187
+ ### Validation commands
188
+
189
+ ```powershell
190
+ cd scribe\frontend
191
+ npm.cmd run lint
192
+ npm.cmd test
193
+ npm.cmd run test:deploy-env
194
+ npm.cmd run build
195
+ npm.cmd run e2e
196
+ npm.cmd run lighthouse
197
+ cd ..\..
198
+ python -m pytest scribe/tests/test_combined_app.py
199
+ ```
200
+
201
+ ### Risks and fallback behavior
202
+
203
+ - The calculator is an estimate based only on answers entered in the browser;
204
+ if JavaScript is unavailable, the surrounding problem and workflow copy still
205
+ explains the value without a numeric claim.
206
+ - The workload source is explicitly dated and must not be presented as a current
207
+ national productivity measurement. International studies are labelled as
208
+ external evidence, not CarePath outcomes.
209
+ - The sample uses sanitized fixed content and leaves Assessment and Plan blank
210
+ for clinician input. It never becomes medical advice.
211
+ - No Interpreter route, API, WebSocket, audio, consent, retention, or safety
212
+ behavior changes.
213
+
214
+ ## Current priority update: professional landing polish
215
+
216
+ ### Current UX problem
217
+
218
+ The problem-led landing (CP-UX-11) has the right content in the right order,
219
+ but its presentation undercuts onboarding:
220
+
221
+ 1. The hero states the problem yet never shows the product. The draft payoff
222
+ sits behind the third tab of the guided sample, so a scanning doctor sees
223
+ no evidence of what CarePath actually produces.
224
+ 2. The two hero actions wrap into a vertical stack at desktop widths and read
225
+ as a rendering fault rather than a hierarchy.
226
+ 3. The Interpreter development notice is a full-width dark band directly under
227
+ the navigation; the most dominant element above the fold announces a
228
+ product that cannot be used.
229
+ 4. The final third of the page is three consecutive dark slabs (trust, start,
230
+ footer) that merge into one block and bury the trust checklist.
231
+ 5. A late `.site-footer` column override defeats the responsive footer rules,
232
+ so footer text squeezes into unreadable columns at phone widths.
233
+ 6. Section paddings differ per chapter, leaving long empty stretches between
234
+ the calculator, sample, and evidence sections on desktop.
235
+
236
+ ### Proposed flow
237
+
238
+ Section order, routes, copy contract, and all interactive behavior stay as
239
+ CP-UX-11 delivered them. Only presentation changes:
240
+
241
+ 1. Render the Interpreter status as a quiet bordered notice under the
242
+ navigation. Same element, same strings, still non-interactive.
243
+ 2. Split the hero: problem headline and actions on the left, and a compact
244
+ conversation-to-draft proof panel on the right that reuses the sanitized
245
+ sample. Subjective and Objective rows only; Assessment and Plan never
246
+ appear before the doctor reaches the guided sample. Actions sit on one row
247
+ on desktop.
248
+ 3. Keep calculator, guided sample, and evidence sections unchanged in
249
+ behavior; align them to one shared section-padding token.
250
+ 4. Restyle the trust section as a light panel so the closing start section is
251
+ the single dark moment before the footer.
252
+ 5. Fix the footer override ordering and stack it into readable rows at phone
253
+ widths.
254
+
255
+ ### Affected routes, pages, and components
256
+
257
+ - `/` in `scribe/frontend/src/LandingPage.tsx` (hero proof panel replaces the
258
+ abstract daily-flow table)
259
+ - `scribe/frontend/src/styles.css` (status notice, hero grid, section rhythm,
260
+ trust section, footer)
261
+ - `scribe/frontend/src/LandingPage.test.tsx` (hero proof assertions)
262
+ - No route, API, form, showcase-interaction, or Scribe-tool changes
263
+
264
+ ### Vietnamese-first copy (new strings only)
265
+
266
+ - Proof panel title: `Từ cuộc trao đổi đến bản nháp`
267
+ - Conversation label: `Trong ghi âm`
268
+ - Reused sample line: `Tôi bị đau tức ngực thoáng qua từ sáng, hơi khó thở.`
269
+ - Draft label: `Bản nháp cho bác sĩ kiểm tra`
270
+ - Draft rows: existing `Triệu chứng chủ quan` and `Thông tin khách quan`
271
+ sample content; the Objective row reads
272
+ `Chưa có kết quả cận lâm sàng trong nội dung ghi âm.`
273
+ - Caption: `Ca khám mẫu đã làm sạch. Nhận định và kế hoạch luôn thuộc về bác sĩ.`
274
+
275
+ ### Implementation story: CP-UX-12
276
+
277
+ - Replace the hero `daily-flow` table with the conversation-to-draft proof
278
+ panel; delete the orphaned `daily-flow` styles.
279
+ - De-emphasize the Interpreter status into a bordered notice.
280
+ - One shared section padding; trust section light; footer stacking fixed at
281
+ its root (remove the post-media-query `.site-footer` override).
282
+ - No new dependency, motion, image, or remote asset. Semantic HTML only.
283
+
284
+ ### Dependencies
285
+
286
+ - CP-UX-11 supplies the problem-led structure, calculator, and guided sample.
287
+ - CP-UX-09 keeps Interpreter browser routes blocked.
288
+ - CP-BASE-003 supplies the site test and deployment gates.
289
+
290
+ ### Acceptance criteria
291
+
292
+ - The hero shows a conversation-to-draft glimpse labeled as a cleaned sample;
293
+ `Chờ bác sĩ nhập hoặc xác nhận.` still never renders before the doctor opens
294
+ the draft step (existing e2e assertion stays green).
295
+ - Both hero actions render on one row at 1024 px and wider.
296
+ - The Interpreter status keeps its exact strings and zero links or buttons,
297
+ and no longer uses the dark band treatment.
298
+ - Exactly one dark section (the start section) renders between the evidence
299
+ grid and the footer.
300
+ - The footer stacks into readable rows at 320, 360, and 390 px.
301
+ - Hero height stays under 800 px at 1440×900; H1 line limits hold; no
302
+ horizontal overflow from 320 to 1440 px.
303
+ - Lint, unit, deploy-env, build with diacritics gate, Playwright, Lighthouse,
304
+ and combined-app checks pass.
305
+
306
+ ### Validation commands
307
+
308
+ ```powershell
309
+ cd scribe\frontend
310
+ npm.cmd run lint
311
+ npm.cmd test
312
+ npm.cmd run test:deploy-env
313
+ npm.cmd run build
314
+ npm.cmd run e2e
315
+ npm.cmd run lighthouse
316
+ cd ..\..
317
+ python -m pytest scribe/tests/test_combined_app.py
318
+ ```
319
+
320
+ ### Risks and fallback behavior
321
+
322
+ - Presentation-only: no route, API, consent, microphone, retention, or safety
323
+ copy changes. The pilot form and showcase interactions are untouched.
324
+ - The proof panel is static text from the existing sanitized sample; if it
325
+ threatens the hero height budget, the conversation line drops first and the
326
+ panel degrades to a draft-only card.
327
+ - All new strings are NFC-normalized Vietnamese; the build diacritics gate is
328
+ the enforcement.
329
+
330
+ ## Current priority update: privacy and Vietnamese-capability visibility
331
+
332
+ ### Current UX problem
333
+
334
+ The landing states its strongest trust facts too quietly. The audio-retention
335
+ claim is one bullet in the closing checklist, the no-training claim from the
336
+ product contract never appears, and nothing explains why the system gets
337
+ clinical Vietnamese right. The page also frames review only as an obligation;
338
+ it never shows that marked corrections and listed gaps make that review fast
339
+ and targeted rather than a full re-read.
340
+
341
+ ### Proposed flow
342
+
343
+ 1. Extend the hero small print with the audio-retention fact so privacy is
344
+ visible before the first action.
345
+ 2. Rework the trust section into two evidence panels under one heading:
346
+ - Vietnamese capability: Vietnamese speech recognition with diacritics
347
+ preserved, term normalization against the curated Vietnamese medical
348
+ lexicon including unaccented and shorthand variants, and visible marks
349
+ for corrections and missing information so review is fast and targeted.
350
+ - Audio privacy: request-scoped temporary processing deleted after the
351
+ request, consultation audio never used as training data, and no
352
+ automatic microphone capture.
353
+ 3. Keep the review requirement and the no-advice boundary in the section
354
+ subline. Never claim review is unnecessary and never state an accuracy
355
+ figure; CarePath has no measured Vietnam deployment data.
356
+
357
+ ### Affected routes, pages, and components
358
+
359
+ - `/` in `scribe/frontend/src/LandingPage.tsx` (hero small print, trust
360
+ section)
361
+ - `scribe/frontend/src/styles.css` (trust panel grid)
362
+ - `scribe/frontend/src/LandingPage.test.tsx` (trust content assertions)
363
+ - No route, API, backend, form, or Scribe-tool changes
364
+
365
+ ### Vietnamese-first copy (new strings)
366
+
367
+ - Hero small print: `Dùng tệp âm thanh đã được phép sử dụng. Âm thanh không
368
+ bị giữ lại sau xử lý; bản nháp không tự đi vào hồ sơ.`
369
+ - Trust heading: `Nhận đúng tiếng Việt. Không giữ lại âm thanh.`
370
+ - Trust subline: `CarePath hỗ trợ ghi chép, không tự tạo chẩn đoán, tư vấn
371
+ hay đơn thuốc. Bác sĩ kiểm tra bản nháp trước khi sử dụng.`
372
+ - Capability panel `Nhận đúng tiếng Việt lâm sàng`:
373
+ - `Mô hình nhận dạng giọng nói tiếng Việt, giữ nguyên dấu và chính tả.`
374
+ - `Thuật ngữ y khoa được đối chiếu với bộ từ điển tiếng Việt tuyển chọn,
375
+ nhận cả cách nói tắt và thiếu dấu.`
376
+ - `Lời chép gốc, chỗ đã chỉnh và phần còn thiếu đều hiển thị rõ, nên bác
377
+ sĩ kiểm tra nhanh và đúng trọng tâm.`
378
+ - Privacy panel `Âm thanh của buổi khám không bị giữ lại`:
379
+ - `Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu và bị xóa ngay khi
380
+ xử lý xong.`
381
+ - `Âm thanh buổi khám không bao giờ được dùng để huấn luyện mô hình.`
382
+ - `Màn hình không tự bắt đầu micro; chỉ dùng tệp cơ sở chủ động tải lên.`
383
+
384
+ ### Implementation story: CP-UX-13
385
+
386
+ - Replace the four-bullet trust list with the two labeled panels; absorb the
387
+ existing bullets so no safety statement is lost.
388
+ - Every claim is verified against the backend and product contract:
389
+ request-scoped `TemporaryDirectory` processing in `scribe/carepath/main.py`,
390
+ the no-training rule in `docs/product/ai-scribe.md`, the Gipformer
391
+ Vietnamese ASR provider, and the curated lexicon with unaccented aliases.
392
+ - No behavior, retention, consent, or API change of any kind.
393
+
394
+ ### Dependencies
395
+
396
+ - CP-UX-12 supplies the current landing presentation.
397
+ - CP-NOTES-02 supplies the approved request-scoped processing phrasing.
398
+ - `docs/product/ai-scribe.md` is the source for privacy claims.
399
+
400
+ ### Acceptance criteria
401
+
402
+ - The audio-retention fact is visible in the hero without scrolling.
403
+ - The trust section shows the capability and privacy panels with all six
404
+ claims, and keeps the review requirement and no-advice boundary visible.
405
+ - No accuracy percentage, no savings promise, and no suggestion that review
406
+ can be skipped appears anywhere on the page.
407
+ - All prior CP-UX-12 acceptance criteria still hold, including e2e, axe,
408
+ diacritics, and Lighthouse gates.
409
+
410
+ ### Validation commands
411
+
412
+ ```powershell
413
+ cd scribe\frontend
414
+ npm.cmd run lint
415
+ npm.cmd test
416
+ npm.cmd run test:deploy-env
417
+ npm.cmd run build
418
+ npm.cmd run e2e
419
+ npm.cmd run lighthouse
420
+ cd ..\..
421
+ python -m pytest scribe/tests/test_combined_app.py
422
+ ```
423
+
424
+ ### Risks and fallback behavior
425
+
426
+ - Presentation and copy only. If any claim cannot be verified in code or
427
+ contract, it is dropped rather than softened; the section degrades to the
428
+ previous checklist.
429
+ - The capability panel must never grow into an accuracy claim. Adding any
430
+ measured-quality statement requires pilot data and an owner decision first.
431
+
432
  ## Backlog rules
433
 
434
  - Implement one story per commit or pull request.
 
456
  2. Every browser request to `/phien-dich-y-khoa/*` and `/console/*` returns
457
  HTTP 404 instead of the Interpreter frontend.
458
  3. Interpreter APIs, WebSockets, backend safety behavior, and automated tests
459
+ remain available only for frozen internal maintenance; no public page links
460
+ to them.
461
 
462
  ### Affected routes, pages, and components
463
 
scribe/frontend/index.html CHANGED
@@ -5,11 +5,11 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta
7
  name="description"
8
- content="CarePath hỗ trợ Ghi chép bệnh án AI Phiên dịch khám bệnh trực tiếp cho sở y tế."
9
  />
10
  <meta name="theme-color" content="#102a2e" />
11
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
12
- <title>CarePath | Ghi chép bệnh án AI Phiên dịch khám bệnh trực tiếp</title>
13
  </head>
14
  <body>
15
  <div id="root"></div>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <meta
7
  name="description"
8
+ content="CarePath chuẩn bị bản nháp ghi chú lâm sàng từ tệp âm thanh để bác sĩ kiểm tra. Phiên dịch trực tiếp đang phát triển chưa mở trên web."
9
  />
10
  <meta name="theme-color" content="#102a2e" />
11
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
12
+ <title>CarePath | Bớt bệnh án, thêm thời gian cho người bệnh</title>
13
  </head>
14
  <body>
15
  <div id="root"></div>
scribe/frontend/src/App.test.tsx CHANGED
@@ -1,67 +1,33 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
  import App from "./App";
4
 
5
- describe("language preference", () => {
6
  beforeEach(() => {
7
  localStorage.clear();
8
  window.history.replaceState(null, "", "/");
9
  document.documentElement.lang = "vi";
10
- document.title = "CarePath | Ghi chép bệnh án AI và Phiên dịch khám bệnh trực tiếp";
11
  });
12
 
13
- it("defaults to Vietnamese and persists a complete English switch", () => {
 
14
  render(<App />);
15
 
16
  expect(document.documentElement.lang).toBe("vi");
17
  expect(document.title).toBe(
18
- "CarePath | Ghi chép bệnh án AI Phiên dịch khám bệnh trực tiếp",
19
  );
20
- fireEvent.click(screen.getByRole("button", { name: "EN" }));
21
-
22
- expect(document.documentElement.lang).toBe("en");
23
- expect(document.title).toBe(
24
- "CarePath | Clinical note drafting and live medical interpretation",
25
- );
26
- expect(localStorage.getItem("carepath-demo-language")).toBe("en");
27
- expect(
28
- screen.getByRole("link", {
29
- name: "CarePath Interpreter: Get Interpreter updates",
30
- }),
31
- ).toBeInTheDocument();
32
- expect(
33
- screen.getByRole("heading", {
34
- name: "Choose the right product for the right point in care.",
35
- }),
36
- ).toBeInTheDocument();
37
- expect(
38
- screen.getByRole("heading", {
39
- name: "One shared oversight layer. Two product-specific safety systems.",
40
- }),
41
- ).toBeInTheDocument();
42
  expect(
43
  screen.getByRole("heading", {
44
- name: "See how each product keeps review points in the right place.",
45
  }),
46
  ).toBeInTheDocument();
 
 
47
  expect(
48
- screen.getByRole("heading", {
49
- name: "Choose the product that fits your workflow.",
50
- }),
51
- ).toBeInTheDocument();
52
- expect(
53
- screen.getByText("AI assistance, clinician in control."),
54
- ).toBeInTheDocument();
55
- });
56
-
57
- it("ignores an invalid stored preference", () => {
58
- localStorage.setItem("carepath-demo-language", "fr");
59
- render(<App />);
60
-
61
- expect(document.documentElement.lang).toBe("vi");
62
- expect(
63
- screen.getByRole("button", { name: "VI" }),
64
- ).toHaveAttribute("aria-pressed", "true");
65
  });
66
 
67
  it("replaces the legacy #/scribe route with the canonical clinical-note path", async () => {
@@ -110,7 +76,9 @@ describe("language preference", () => {
110
  window.dispatchEvent(new PopStateEvent("popstate"));
111
 
112
  expect(
113
- await screen.findByRole("heading", { name: "Bạn muốn hỗ trợ việc gì hôm nay?" }),
 
 
114
  ).toBeInTheDocument();
115
  vi.unstubAllGlobals();
116
  });
 
1
+ import { render, screen } from "@testing-library/react";
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
  import App from "./App";
4
 
5
+ describe("Vietnamese-only public app", () => {
6
  beforeEach(() => {
7
  localStorage.clear();
8
  window.history.replaceState(null, "", "/");
9
  document.documentElement.lang = "vi";
10
+ document.title = "CarePath";
11
  });
12
 
13
+ it("always renders Vietnamese and exposes no language switch", () => {
14
+ localStorage.setItem("carepath-demo-language", "en");
15
  render(<App />);
16
 
17
  expect(document.documentElement.lang).toBe("vi");
18
  expect(document.title).toBe(
19
+ "CarePath | Bớt bệnh án, thêm thời gian cho người bệnh",
20
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  expect(
22
  screen.getByRole("heading", {
23
+ name: "Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.",
24
  }),
25
  ).toBeInTheDocument();
26
+ expect(screen.queryByRole("button", { name: "VI" })).not.toBeInTheDocument();
27
+ expect(screen.queryByRole("button", { name: "EN" })).not.toBeInTheDocument();
28
  expect(
29
+ document.querySelector('a[href*="phien-dich-y-khoa"], a[href*="console"]'),
30
+ ).toBeNull();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  });
32
 
33
  it("replaces the legacy #/scribe route with the canonical clinical-note path", async () => {
 
76
  window.dispatchEvent(new PopStateEvent("popstate"));
77
 
78
  expect(
79
+ await screen.findByRole("heading", {
80
+ name: "Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.",
81
+ }),
82
  ).toBeInTheDocument();
83
  vi.unstubAllGlobals();
84
  });
scribe/frontend/src/App.tsx CHANGED
@@ -2,26 +2,20 @@ import { useEffect, useRef, useState } from "react";
2
  import LandingPage from "./LandingPage";
3
  import ScribeTool from "./scribe/ScribeTool";
4
  import { copyFor } from "./content/strings";
5
- import type { Language } from "./demo/types";
6
 
7
  const CLINICAL_NOTES_PATH = "/ghi-chep-lam-sang/";
8
 
9
  export default function App() {
10
- const [language, setLanguage] = useState<Language>(() => {
11
- const saved = localStorage.getItem("carepath-demo-language");
12
- return saved === "en" ? "en" : "vi";
13
- });
14
  const [pathname, setPathname] = useState(() => window.location.pathname);
15
  const wasScribeTool = useRef(false);
16
 
17
  useEffect(() => {
18
- document.documentElement.lang = language;
19
- document.title = copyFor(language).metadata.title;
20
  document
21
  .querySelector('meta[name="description"]')
22
- ?.setAttribute("content", copyFor(language).metadata.description);
23
- localStorage.setItem("carepath-demo-language", language);
24
- }, [language]);
25
 
26
  useEffect(() => {
27
  if (window.location.hash === "#/scribe" || pathname === "/ghi-chep-lam-sang") {
@@ -63,8 +57,8 @@ export default function App() {
63
  }, [isScribeTool]);
64
 
65
  return isScribeTool ? (
66
- <ScribeTool backHref="/" language={language} onLanguageChange={setLanguage} />
67
  ) : (
68
- <LandingPage language={language} onLanguageChange={setLanguage} />
69
  );
70
  }
 
2
  import LandingPage from "./LandingPage";
3
  import ScribeTool from "./scribe/ScribeTool";
4
  import { copyFor } from "./content/strings";
 
5
 
6
  const CLINICAL_NOTES_PATH = "/ghi-chep-lam-sang/";
7
 
8
  export default function App() {
 
 
 
 
9
  const [pathname, setPathname] = useState(() => window.location.pathname);
10
  const wasScribeTool = useRef(false);
11
 
12
  useEffect(() => {
13
+ document.documentElement.lang = "vi";
14
+ document.title = copyFor("vi").metadata.title;
15
  document
16
  .querySelector('meta[name="description"]')
17
+ ?.setAttribute("content", copyFor("vi").metadata.description);
18
+ }, []);
 
19
 
20
  useEffect(() => {
21
  if (window.location.hash === "#/scribe" || pathname === "/ghi-chep-lam-sang") {
 
57
  }, [isScribeTool]);
58
 
59
  return isScribeTool ? (
60
+ <ScribeTool backHref="/" />
61
  ) : (
62
+ <LandingPage />
63
  );
64
  }
scribe/frontend/src/LandingPage.test.tsx CHANGED
@@ -1,100 +1,104 @@
1
- import { render, screen } from "@testing-library/react";
2
  import { describe, expect, it } from "vitest";
3
- import LandingPage from "./LandingPage";
4
- import { copyFor, type ProductKey } from "./content/strings";
5
-
6
- function expectProductCopy(language: "vi" | "en") {
7
- const copy = copyFor(language);
8
- for (const key of ["interpreter", "scribe"] satisfies ProductKey[]) {
9
- const product = copy.products[key];
10
- expect(screen.getAllByText(product.name).length).toBeGreaterThan(0);
11
- expect(screen.getByText(product.body)).toBeInTheDocument();
12
- expect(screen.getByText(product.audience)).toBeInTheDocument();
13
- expect(screen.getByText(product.input)).toBeInTheDocument();
14
- expect(screen.getByText(product.output)).toBeInTheDocument();
15
- expect(screen.getByText(product.status)).toBeInTheDocument();
16
- expect(screen.getByText(product.helper)).toBeInTheDocument();
17
- expect(screen.getByText(product.timing)).toBeInTheDocument();
18
- expect(screen.getByText(product.chooserSafety)).toBeInTheDocument();
19
- expect(screen.getByRole("link", {
20
- name: `${product.name}: ${product.cta.open}`,
21
- })).toBeInTheDocument();
22
- }
23
- }
24
 
25
  describe("LandingPage", () => {
26
- it("renders the Vietnamese evidence-led page", () => {
27
- render(<LandingPage language="vi" />);
28
 
29
  expect(
30
  screen.getByRole("heading", {
31
  level: 1,
32
- name: "Bạn muốn hỗ trợ việc hôm nay?",
33
  }),
34
  ).toBeInTheDocument();
35
  expect(
36
- screen.getByRole("heading", {
37
- name: "Một lớp giám sát chung. Hai cơ chế an toàn riêng.",
38
- }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  ).toBeInTheDocument();
40
- expect(screen.getByRole("heading", { name: "Phiên dịch khám bệnh trực tiếp" })).toBeInTheDocument();
41
- expect(screen.getByRole("heading", { name: "Ghi chép bệnh án AI" })).toBeInTheDocument();
42
- expect(screen.getByRole("heading", { name: "Bác sĩ kiểm tra trước khi dùng" })).toBeInTheDocument();
43
- expect(screen.getByRole("heading", { name: "Độ không chắc chắn luôn hiển thị" })).toBeInTheDocument();
44
- expect(screen.getByRole("heading", { name: "Không thay thế chẩn đoán hoặc tư vấn" })).toBeInTheDocument();
45
  expect(
46
- screen.getByRole("link", { name: "Đọc báo cáo nghiên cứu nguồn trích dẫn" }),
47
  ).toBeInTheDocument();
48
- expect(screen.queryByText("TODO-pricing")).not.toBeInTheDocument();
49
- expectProductCopy("vi");
50
 
51
- const scribe = screen.getByRole("link", {
52
- name: "Ghi chép bệnh án AI: Bắt đầu ghi chép",
53
- });
54
- const interpreter = screen.getByRole("link", {
55
- name: "Phiên dịch khám bệnh trực tiếp: Đăng ký nhận cập nhật",
56
  });
57
- expect(scribe).toHaveAttribute("href", "/ghi-chep-lam-sang/");
58
- expect(interpreter).toHaveAttribute("href", "#pilot");
59
- expect(
60
- [...document.querySelectorAll(".product-accordion__panel")].map((panel) => panel.className),
61
- ).toEqual([
62
- "product-accordion__panel product-accordion__panel--scribe",
63
- "product-accordion__panel product-accordion__panel--interpreter",
64
- ]);
 
65
  });
66
 
67
- it("renders the complete page in English", () => {
68
- render(<LandingPage language="en" />);
69
 
70
- expect(screen.getByRole("heading", {
71
- name: "Choose the right product for the right point in care.",
72
- })).toBeInTheDocument();
73
  expect(
74
  screen.getByRole("heading", {
75
- name: "One shared oversight layer. Two product-specific safety systems.",
76
  }),
77
  ).toBeInTheDocument();
78
  expect(
79
- screen.getByRole("heading", {
80
- name: "Choose the product that fits your workflow.",
81
- }),
82
  ).toBeInTheDocument();
83
  expect(
84
- screen.getAllByText("In development for the web."),
85
- ).toHaveLength(1);
 
 
 
86
  expect(
87
- screen.getAllByText("Pilot tool every draft requires clinician review."),
88
- ).toHaveLength(1);
89
- expect(screen.getAllByText("Sanitized sample data")).toHaveLength(2);
90
- expectProductCopy("en");
91
  });
92
 
93
- it("keeps the pilot form after the decision gateway", () => {
94
- render(<LandingPage language="vi" />);
95
 
 
96
  expect(screen.getByRole("textbox", { name: "Cơ sở y tế" })).toHaveValue("");
97
- expect(screen.getByRole("textbox", { name: "Chuyên khoa" })).toHaveValue("");
98
- expect(screen.getByRole("combobox", { name: "Chức năng quan tâm" })).toHaveValue("both");
 
 
 
 
 
 
 
 
 
 
99
  });
100
  });
 
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
  import { describe, expect, it } from "vitest";
3
+ import LandingPage, { formatMinutes } from "./LandingPage";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  describe("LandingPage", () => {
6
+ it("leads with the doctor's problem and keeps Interpreter unavailable", () => {
7
+ render(<LandingPage />);
8
 
9
  expect(
10
  screen.getByRole("heading", {
11
  level: 1,
12
+ name: "Dành thời gian cho người bệnh, không phải cho việc bệnh án.",
13
  }),
14
  ).toBeInTheDocument();
15
  expect(
16
+ screen.getByRole("link", { name: "Trải nghiệm ca khám mẫu" }),
17
+ ).toHaveAttribute("href", "#demo");
18
+ expect(
19
+ screen.getByRole("link", { name: "Tính thời gian ghi chép" }),
20
+ ).toHaveAttribute("href", "#calculator");
21
+ expect(screen.queryByRole("button", { name: "EN" })).not.toBeInTheDocument();
22
+
23
+ const status = document.querySelector<HTMLElement>("[data-interpreter-status]");
24
+ expect(status).toHaveTextContent("Phiên dịch khám bệnh trực tiếp");
25
+ expect(status).toHaveTextContent("Đang phát triển — hiện chưa thể truy cập trên web.");
26
+ expect(status?.querySelector("a, button")).toBeNull();
27
+ expect(
28
+ document.querySelector('a[href*="phien-dich-y-khoa"], a[href*="console"]'),
29
+ ).toBeNull();
30
+ });
31
+
32
+ it("shows the conversation-to-draft proof without inventing assessment or plan", () => {
33
+ render(<LandingPage />);
34
+
35
+ expect(screen.getByText("Từ cuộc trao đổi đến bản nháp")).toBeInTheDocument();
36
+ expect(
37
+ screen.getByText("Chưa có kết quả cận lâm sàng trong nội dung ghi âm."),
38
  ).toBeInTheDocument();
 
 
 
 
 
39
  expect(
40
+ screen.getByText(/Nhận định kế hoạch luôn thuộc về bác sĩ/),
41
  ).toBeInTheDocument();
42
+ expect(screen.queryByText("Chờ bác sĩ nhập hoặc xác nhận.")).not.toBeInTheDocument();
43
+ });
44
 
45
+ it("calculates current documentation time without claiming CarePath savings", () => {
46
+ render(<LandingPage />);
47
+
48
+ fireEvent.change(screen.getByRole("spinbutton", { name: "Số người bệnh mỗi ngày" }), {
49
+ target: { value: "28" },
50
  });
51
+ fireEvent.change(
52
+ screen.getByRole("spinbutton", { name: "Phút ghi chép cho mỗi người bệnh" }),
53
+ { target: { value: "5" } },
54
+ );
55
+
56
+ expect(screen.getByText("2 giờ 20 phút mỗi ngày")).toBeInTheDocument();
57
+ expect(screen.getByText("11 giờ 40 phút cho 5 ngày làm việc")).toBeInTheDocument();
58
+ expect(screen.getByText(/Công thức: 28 người bệnh × 5 phút/)).toBeInTheDocument();
59
+ expect(screen.queryByText(/CarePath tiết kiệm/i)).not.toBeInTheDocument();
60
  });
61
 
62
+ it("surfaces verified privacy and Vietnamese-capability claims with review intact", () => {
63
+ render(<LandingPage />);
64
 
65
+ expect(screen.getByText(/Âm thanh không bị giữ lại sau xử lý/)).toBeInTheDocument();
 
 
66
  expect(
67
  screen.getByRole("heading", {
68
+ name: "Nhận đúng tiếng Việt. Không giữ lại âm thanh.",
69
  }),
70
  ).toBeInTheDocument();
71
  expect(
72
+ screen.getByText(/Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu/),
 
 
73
  ).toBeInTheDocument();
74
  expect(
75
+ screen.getByText("Âm thanh buổi khám không bao giờ được dùng để huấn luyện mô hình."),
76
+ ).toBeInTheDocument();
77
+ expect(
78
+ screen.getByText(/bác sĩ kiểm tra nhanh và đúng trọng tâm/),
79
+ ).toBeInTheDocument();
80
  expect(
81
+ screen.getByText(/không tự tạo chẩn đoán, vấn hay đơn thuốc/),
82
+ ).toBeInTheDocument();
83
+ expect(screen.getByText(/Bác kiểm tra bản nháp trước khi sử dụng/)).toBeInTheDocument();
 
84
  });
85
 
86
+ it("keeps the pilot form scoped to Scribe interest", () => {
87
+ render(<LandingPage />);
88
 
89
+ fireEvent.click(screen.getByText("Dành cho cơ sở muốn thí điểm CarePath"));
90
  expect(screen.getByRole("textbox", { name: "Cơ sở y tế" })).toHaveValue("");
91
+ expect(screen.queryByRole("combobox", { name: "Chức năng quan tâm" })).not.toBeInTheDocument();
92
+ expect(
93
+ screen.getByText(/Biểu mẫu này chỉ dành cho Ghi chép bệnh án AI/),
94
+ ).toBeInTheDocument();
95
+ });
96
+ });
97
+
98
+ describe("formatMinutes", () => {
99
+ it("formats whole hours and remaining minutes", () => {
100
+ expect(formatMinutes(140)).toBe("2 giờ 20 phút");
101
+ expect(formatMinutes(120)).toBe("2 giờ");
102
+ expect(formatMinutes(45)).toBe("45 phút");
103
  });
104
  });
scribe/frontend/src/LandingPage.tsx CHANGED
@@ -1,58 +1,53 @@
1
  import { useRef, useState } from "react";
2
  import logoUrl from "./assets/carepath.svg";
3
- import { copyFor, sources, type ProductKey } from "./content/strings";
4
- import { getScenario, scenarios } from "./demo/scenarios";
5
- import type { Language } from "./demo/types";
6
  import LeadForm from "./LeadForm";
7
- import { leadContact, zaloHref, type ProductInterest } from "./leads";
8
- import { soapDraft } from "./scribe/ScribeShowcase";
9
 
10
- interface LandingPageProps {
11
- language: Language;
12
- onLanguageChange?: (language: Language) => void;
13
- }
14
 
15
- const productOrder: ProductKey[] = ["scribe", "interpreter"];
16
- const interpreterEvidenceTurn = getScenario("allergy").turns[3];
 
 
 
 
 
 
17
 
18
- export default function LandingPage({
19
- language,
20
- onLanguageChange,
21
- }: LandingPageProps) {
22
  const menuRef = useRef<HTMLDetailsElement>(null);
23
- const copy = copyFor(language);
24
- const [interest, setInterest] = useState<ProductInterest>("both");
25
- const scenario = scenarios[0];
26
- const scribeHref = "/ghi-chep-lam-sang/";
 
27
 
28
  const navLinks = (
29
  <>
30
- <a href="#products">{copy.nav.interpreter}</a>
31
- <a href="#products">{copy.nav.scribe}</a>
32
- <a href="#safety">{copy.nav.safety}</a>
33
- <a href="#pilot">{copy.nav.pilot}</a>
34
  </>
35
  );
36
 
37
  return (
38
  <div className="site-shell">
39
- <nav
40
- className="site-nav"
41
- aria-label={language === "vi" ? "Điều hướng chính" : "Main navigation"}
42
- >
43
  <a className="site-nav__brand" href="#top" aria-label="CarePath">
44
  <img src={logoUrl} alt="" />
45
  <span>CarePath</span>
46
  </a>
47
  <div className="site-nav__links">{navLinks}</div>
48
  <details className="site-nav__menu" ref={menuRef}>
49
- <summary>{copy.nav.menu}</summary>
50
  <div
51
  onClick={(event) => {
52
- if (
53
- menuRef.current &&
54
- (event.target as HTMLElement).closest("a")
55
- ) {
56
  menuRef.current.open = false;
57
  }
58
  }}
@@ -60,198 +55,275 @@ export default function LandingPage({
60
  {navLinks}
61
  </div>
62
  </details>
63
- <div className="site-nav__actions">
64
- <div className="language-toggle" aria-label={copy.language.label}>
65
- <button
66
- aria-pressed={language === "vi"}
67
- onClick={() => onLanguageChange?.("vi")}
68
- type="button"
69
- >
70
- {copy.language.vi}
71
- </button>
72
- <button
73
- aria-pressed={language === "en"}
74
- onClick={() => onLanguageChange?.("en")}
75
- type="button"
76
- >
77
- {copy.language.en}
78
- </button>
79
- </div>
80
- </div>
81
  </nav>
82
 
83
- <main id="top" tabIndex={-1}>
84
- <section className="product-gateway" id="products" aria-labelledby="gateway-title">
85
- <header className="section-intro section-intro--wide">
86
- <h1 id="gateway-title">{copy.gateway.heading}</h1>
87
- <p>{copy.gateway.body}</p>
88
- </header>
89
- <div className="product-accordion">
90
- {productOrder.map((key) => {
91
- const product = copy.products[key];
92
- const isInterpreter = key === "interpreter";
93
- return (
94
- <a
95
- className={`product-accordion__panel product-accordion__panel--${key}`}
96
- aria-label={`${product.name}: ${product.cta.open}`}
97
- href={isInterpreter ? "#pilot" : scribeHref}
98
- key={key}
99
- onClick={isInterpreter ? () => setInterest("interpreter") : undefined}
100
- >
101
- <div className="product-accordion__heading">
102
- <h2>{product.name}</h2>
103
- <span className="product-accordion__helper">{product.helper}</span>
104
- <span className="product-accordion__status">{product.status}</span>
105
- </div>
106
- <p>{product.body}</p>
107
- <p className="product-accordion__timing">{product.timing}</p>
108
- <dl>
109
- <div>
110
- <dt>{copy.gateway.useWhen}</dt>
111
- <dd>{product.audience}</dd>
112
- </div>
113
- <div>
114
- <dt>{copy.gateway.input}</dt>
115
- <dd>{product.input}</dd>
116
- </div>
117
- <div>
118
- <dt>{copy.gateway.output}</dt>
119
- <dd>{product.output}</dd>
120
- </div>
121
- </dl>
122
- <p className="product-accordion__safety">{product.chooserSafety}</p>
123
- <strong className="product-accordion__cta">
124
- <span aria-hidden="true">{key === "interpreter" ? "↔" : "☷"}</span>
125
- {product.cta.open}
126
- </strong>
127
  </a>
128
- );
129
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  </div>
131
  </section>
132
 
133
- <section className="trust-strip" aria-label={copy.safety.title}>
134
- {copy.safety.trust.map((item) => (
135
- <article key={item.title}>
136
- <h2>{item.title}</h2>
137
- <p>{item.body}</p>
138
- </article>
139
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </section>
141
 
142
- <section
143
- className="safety-suite"
144
- id="safety"
145
- aria-labelledby="safety-title"
146
- tabIndex={-1}
147
- >
148
- <header className="section-intro section-intro--on-dark">
149
- <h2 id="safety-title">{copy.safety.title}</h2>
150
- <p>{copy.safety.body}</p>
151
  </header>
152
- <div className="safety-bento">
153
- {(
154
- [
155
- ["shared", copy.safety.shared],
156
- ["interpreter", copy.safety.interpreter],
157
- ["scribe", copy.safety.scribe],
158
- ] as const
159
- ).map(([key, card]) => (
160
- <article
161
- className={`safety-bento__card safety-bento__card--${key}`}
162
- data-safety-card
163
- key={key}
164
- >
165
- <header>
166
- <h3>{card.title}</h3>
167
- <p>{card.body}</p>
168
- </header>
169
- <ul>
170
- {card.items.map((item) => (
171
- <li key={item.title.vi}>
172
- <strong>{item.title[language]}</strong>
173
- <span>{item.body[language]}</span>
174
- </li>
175
- ))}
176
- </ul>
177
- </article>
178
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  </div>
180
  </section>
181
 
182
- <section className="evidence" aria-labelledby="evidence-title">
183
- <header className="section-intro">
184
- <h2 id="evidence-title">{copy.evidence.title}</h2>
185
- <p>{copy.evidence.body}</p>
186
- <a href={sources.research.href} target="_blank" rel="noreferrer">
187
- {copy.evidence.source}
188
- </a>
189
  </header>
190
- <div className="evidence-proof-grid">
191
- {copy.evidence.items.map((item) => (
192
- <article className={`evidence-proof evidence-proof--${item.kind}`} key={item.kind}>
193
- <div className="evidence-proof__copy">
194
- <p>{item.kind === "research" ? copy.evidence.researchLabel : copy.evidence.sampleLabel}</p>
195
- <h3>{item.title}</h3>
196
- <p>{item.body}</p>
197
- </div>
198
- <div className="evidence-capture" aria-label={item.detail} role="img">
199
- <div className="evidence-capture__bar">
200
- <span>CarePath</span>
201
- <span>{item.product}</span>
202
- </div>
203
- <div className="evidence-capture__content">
204
- {item.kind === "interpreter" && (
205
- <>
206
- <div className="evidence-capture__turn">
207
- <small>EN · {copy.demo.patient}</small>
208
- <p lang="en">{interpreterEvidenceTurn.en}</p>
209
- </div>
210
- <div className="evidence-capture__turn">
211
- <small>VI · {copy.demo.translation}</small>
212
- <p lang="vi">{interpreterEvidenceTurn.vi}</p>
213
- </div>
214
- </>
215
- )}
216
- {item.kind === "scribe" && (
217
- <dl>
218
- {soapDraft.slice(0, 2).map((row) => (
219
- <div key={row.key}>
220
- <dt>{copy.scribe.soapLabels[row.key]}</dt>
221
- <dd lang="vi">{row.text}</dd>
222
- </div>
223
- ))}
224
- </dl>
225
- )}
226
- {item.kind === "research" && (
227
- <p className="evidence-capture__source">docs/research.md</p>
228
- )}
229
- <strong>{item.detail}</strong>
230
- </div>
231
- </div>
232
- </article>
233
- ))}
234
  </div>
235
  </section>
236
 
237
- <section className="chapter pilot" id="pilot" tabIndex={-1}>
238
- <div className="pilot__copy">
239
- <h2>{copy.pilot.title}</h2>
240
- <p>{copy.pilot.body}</p>
241
- <div className="pilot__configuration">
242
- <span>{copy.form.interestOptions[interest]}</span>
243
- </div>
244
- <small>{copy.pilot.transcriptNote}</small>
 
 
245
  </div>
246
- <LeadForm
247
- clinic=""
248
- specialty=""
249
- scenario={scenario}
250
- transcript=""
251
- language={language}
252
- interest={interest}
253
- onInterestChange={setInterest}
254
- />
 
 
 
 
 
 
 
 
 
255
  </section>
256
  </main>
257
 
@@ -260,7 +332,6 @@ export default function LandingPage({
260
  <img src={logoUrl} alt="" />
261
  <strong>{copy.footer.promise}</strong>
262
  </div>
263
- <p>{copy.footer.posture}</p>
264
  <p>{copy.footer.honesty}</p>
265
  <div>
266
  <a href={`mailto:${leadContact.email}`}>{copy.footer.contact}</a>
 
1
  import { useRef, useState } from "react";
2
  import logoUrl from "./assets/carepath.svg";
3
+ import { copyFor } from "./content/strings";
4
+ import { scenarios } from "./demo/scenarios";
 
5
  import LeadForm from "./LeadForm";
6
+ import { leadContact, zaloHref } from "./leads";
7
+ import ScribeShowcase from "./scribe/ScribeShowcase";
8
 
9
+ const scribeHref = "/ghi-chep-lam-sang/";
 
 
 
10
 
11
+ export function formatMinutes(total: number): string {
12
+ const rounded = Math.max(0, Math.round(total));
13
+ const hours = Math.floor(rounded / 60);
14
+ const minutes = rounded % 60;
15
+ if (hours === 0) return `${minutes} phút`;
16
+ if (minutes === 0) return `${hours} giờ`;
17
+ return `${hours} giờ ${minutes} phút`;
18
+ }
19
 
20
+ export default function LandingPage() {
21
+ const copy = copyFor("vi");
 
 
22
  const menuRef = useRef<HTMLDetailsElement>(null);
23
+ const [patients, setPatients] = useState(28);
24
+ const [minutesPerPatient, setMinutesPerPatient] = useState(5);
25
+ const [afterHours, setAfterHours] = useState("sometimes");
26
+ const [workplace, setWorkplace] = useState("clinic");
27
+ const dailyMinutes = patients * minutesPerPatient;
28
 
29
  const navLinks = (
30
  <>
31
+ <a href="#calculator">Thời gian ghi chép</a>
32
+ <a href="#demo">Ca khám mẫu</a>
33
+ <a href="#evidence">Bằng chứng</a>
34
+ <a href="#start">Bắt đầu</a>
35
  </>
36
  );
37
 
38
  return (
39
  <div className="site-shell">
40
+ <nav className="site-nav" aria-label="Điều hướng chính">
 
 
 
41
  <a className="site-nav__brand" href="#top" aria-label="CarePath">
42
  <img src={logoUrl} alt="" />
43
  <span>CarePath</span>
44
  </a>
45
  <div className="site-nav__links">{navLinks}</div>
46
  <details className="site-nav__menu" ref={menuRef}>
47
+ <summary>Mở điều hướng</summary>
48
  <div
49
  onClick={(event) => {
50
+ if (menuRef.current && (event.target as HTMLElement).closest("a")) {
 
 
 
51
  menuRef.current.open = false;
52
  }
53
  }}
 
55
  {navLinks}
56
  </div>
57
  </details>
58
+ <a className="nav-cta" href={scribeHref}>Bắt đầu ghi chép</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  </nav>
60
 
61
+ <aside
62
+ className="interpreter-status"
63
+ aria-labelledby="interpreter-status-title"
64
+ data-interpreter-status
65
+ >
66
+ <strong id="interpreter-status-title">Phiên dịch khám bệnh trực tiếp</strong>
67
+ <span>Đang phát triển — hiện chưa thể truy cập trên web.</span>
68
+ </aside>
69
+
70
+ <main className="landing-main" id="top" tabIndex={-1}>
71
+ <section className="onboarding-hero" aria-labelledby="onboarding-title">
72
+ <h1 id="onboarding-title">
73
+ Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.
74
+ </h1>
75
+ <div className="onboarding-hero__body">
76
+ <div className="onboarding-hero__copy">
77
+ <p>
78
+ CarePath chuẩn bị ghi chú lâm sàng từ cuộc trao đổi để bác sĩ
79
+ kiểm tra và xác nhận.
80
+ </p>
81
+ <div className="onboarding-hero__actions">
82
+ <a className="button-link button-link--primary" href="#demo">
83
+ Trải nghiệm ca khám mẫu
84
+ </a>
85
+ <a className="button-link button-link--secondary" href="#calculator">
86
+ Tính thời gian ghi chép
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  </a>
88
+ </div>
89
+ <small>
90
+ Dùng tệp âm thanh đã được phép sử dụng. Âm thanh không bị giữ
91
+ lại sau xử lý; bản nháp không tự đi vào hồ sơ.
92
+ </small>
93
+ </div>
94
+
95
+ <div className="hero-proof" aria-label="Từ cuộc trao đổi đến bản nháp">
96
+ <p className="hero-proof__title">Từ cuộc trao đổi đến bản nháp</p>
97
+ <div className="hero-proof__stage">
98
+ <span>Trong ghi âm</span>
99
+ <p>
100
+ <strong>Bệnh nhân:</strong> Tôi bị đau tức ngực thoáng qua từ
101
+ sáng, hơi khó thở.
102
+ </p>
103
+ </div>
104
+ <div className="hero-proof__stage hero-proof__stage--draft">
105
+ <span>Bản nháp cho bác sĩ kiểm tra</span>
106
+ <dl>
107
+ <div>
108
+ <dt>Triệu chứng chủ quan</dt>
109
+ <dd>Đau tức ngực thoáng qua từ sáng, khó thở nhẹ. Chưa dùng thuốc.</dd>
110
+ </div>
111
+ <div>
112
+ <dt>Thông tin khách quan</dt>
113
+ <dd>Chưa có kết quả cận lâm sàng trong nội dung ghi âm.</dd>
114
+ </div>
115
+ </dl>
116
+ </div>
117
+ <p className="hero-proof__caption">
118
+ Ca khám mẫu đã làm sạch. Nhận định và kế hoạch luôn thuộc về bác sĩ.
119
+ </p>
120
+ </div>
121
  </div>
122
  </section>
123
 
124
+ <section className="burden-section" id="calculator" aria-labelledby="burden-title">
125
+ <header className="compact-heading">
126
+ <h2 id="burden-title">Một buổi khám không nên biến thành hai ca làm việc.</h2>
127
+ <p>
128
+ Hãy bắt đầu bằng thời gian bác sĩ đang dành cho ghi chép, không
129
+ phải bằng một lời hứa tiết kiệm chưa được kiểm chứng.
130
+ </p>
131
+ </header>
132
+
133
+ <div className="burden-layout">
134
+ <form className="burden-calculator" onSubmit={(event) => event.preventDefault()}>
135
+ <h3>Ước tính gánh nặng hiện tại</h3>
136
+ <p>Con số chỉ dựa trên câu trả lời của bạn và không được gửi đi.</p>
137
+ <div className="calculator-fields">
138
+ <label>
139
+ Số người bệnh mỗi ngày
140
+ <input
141
+ max="200"
142
+ min="0"
143
+ onChange={(event) => setPatients(Math.min(200, Math.max(0, Number(event.target.value))))}
144
+ type="number"
145
+ value={patients}
146
+ />
147
+ </label>
148
+ <label>
149
+ Phút ghi chép cho mỗi người bệnh
150
+ <input
151
+ max="120"
152
+ min="0"
153
+ onChange={(event) => setMinutesPerPatient(Math.min(120, Math.max(0, Number(event.target.value))))}
154
+ type="number"
155
+ value={minutesPerPatient}
156
+ />
157
+ </label>
158
+ <label>
159
+ Bạn thường hoàn tất bệnh án sau giờ làm?
160
+ <select onChange={(event) => setAfterHours(event.target.value)} value={afterHours}>
161
+ <option value="never">Hiếm khi</option>
162
+ <option value="sometimes">Một vài ngày trong tuần</option>
163
+ <option value="often">Gần như mỗi ngày</option>
164
+ </select>
165
+ </label>
166
+ <label>
167
+ Nơi làm việc chính
168
+ <select onChange={(event) => setWorkplace(event.target.value)} value={workplace}>
169
+ <option value="clinic">Phòng khám</option>
170
+ <option value="hospital">Bệnh viện</option>
171
+ <option value="other">Cơ sở khác</option>
172
+ </select>
173
+ </label>
174
+ </div>
175
+ <output className="calculator-result" aria-live="polite">
176
+ <span>Thời gian ghi chép ước tính hiện tại</span>
177
+ <strong>{formatMinutes(dailyMinutes)} mỗi ngày</strong>
178
+ <small>{formatMinutes(dailyMinutes * 5)} cho 5 ngày làm việc</small>
179
+ </output>
180
+ <p className="calculator-formula">
181
+ Công thức: {patients} người bệnh × {minutesPerPatient} phút. Tần
182
+ suất ngoài giờ và nơi làm việc giúp bạn tự đối chiếu bối cảnh,
183
+ không làm thay đổi phép tính.
184
+ </p>
185
+ </form>
186
+
187
+ <div className="workflow-comparison">
188
+ <article>
189
+ <h3>Khi chưa có bản nháp</h3>
190
+ <p>Nhớ lại cuộc trao đổi, gõ lại từng mục, rồi tự tìm phần còn thiếu.</p>
191
+ </article>
192
+ <article>
193
+ <h3>Khi dùng CarePath</h3>
194
+ <p>
195
+ Tải tệp đã được phép sử dụng, đọc lại lời chép và bắt đầu từ
196
+ một bản nháp có cấu trúc.
197
+ </p>
198
+ </article>
199
+ <p>Bác sĩ vẫn là người sửa, bổ sung và quyết định bản cuối.</p>
200
+ </div>
201
+ </div>
202
  </section>
203
 
204
+ <section className="demo-section" id="demo" aria-labelledby="demo-title">
205
+ <header className="compact-heading compact-heading--wide">
206
+ <h2 id="demo-title">Thử một ca khám mẫu trước khi tải tệp của bạn.</h2>
207
+ <p>
208
+ Đi qua bốn bước ngắn để thấy nội dung nào đến từ cuộc trao đổi,
209
+ nội dung nào được làm rõ và phần nào bác sĩ phải tự xác nhận.
210
+ </p>
 
 
211
  </header>
212
+ <ScribeShowcase />
213
+ </section>
214
+
215
+ <section className="evidence-section" id="evidence" aria-labelledby="evidence-title">
216
+ <header className="compact-heading compact-heading--wide">
217
+ <h2 id="evidence-title">Bằng chứng được đặt đúng giới hạn.</h2>
218
+ <p>
219
+ Nhu cầu là có thật. Kết quả quốc tế đáng để học hỏi. Hiệu quả của
220
+ CarePath tại Việt Nam vẫn cần được đo trong thí điểm.
221
+ </p>
222
+ </header>
223
+ <div className="evidence-grid">
224
+ <article>
225
+ <h3>Bối cảnh Việt Nam</h3>
226
+ <p>
227
+ Một định mức ban hành năm 2015 từng đặt 33–45 lượt khám mỗi ngày
228
+ tùy hạng bệnh viện, tương đương khoảng 11–15 phút cho mỗi lượt
229
+ trong 8 giờ. Đây là mốc lịch sử, không phải số đo hiện tại.
230
+ </p>
231
+ <a href="https://baohiemxahoi.gov.vn/tintuc/Pages/linh-vuc-bao-hiem-xa-hoi.aspx?CateID=0&ItemID=8449">
232
+ Xem nguồn Bảo hiểm xã hội Việt Nam
233
+ </a>
234
+ </article>
235
+ <article>
236
+ <h3>Kinh nghiệm quốc tế</h3>
237
+ <p>
238
+ Các nghiên cứu quan sát tại Singapore và Hoa Kỳ ghi nhận ít thời
239
+ gian ghi chép hơn, nhiều giao tiếp bằng mắt hơn hoặc giảm kiệt
240
+ sức sau khi dùng công cụ ghi chép môi trường. Đây không phải kết
241
+ quả của CarePath.
242
+ </p>
243
+ <div className="evidence-links">
244
+ <a href="https://medinform.jmir.org/2026/1/e85580">Nghiên cứu JMIR 2026</a>
245
+ <a href="https://jamanetwork.com/journals/jamanetworkopen/fullarticle/2839542">
246
+ Nghiên cứu JAMA Network Open
247
+ </a>
248
+ </div>
249
+ </article>
250
+ <article>
251
+ <h3>Điều CarePath còn phải chứng minh</h3>
252
+ <p>
253
+ Chưa có dữ liệu triển khai CarePath tại Việt Nam. Thí điểm cần đo
254
+ thời gian hoàn tất bệnh án, lỗi bác sĩ phải sửa và mức chấp nhận
255
+ của người dùng trước khi đưa ra tuyên bố hiệu quả.
256
+ </p>
257
+ </article>
258
  </div>
259
  </section>
260
 
261
+ <section className="trust-section" aria-labelledby="trust-title">
262
+ <header>
263
+ <h2 id="trust-title">Nhận đúng tiếng Việt. Không giữ lại âm thanh.</h2>
264
+ <p>
265
+ CarePath hỗ trợ ghi chép, không tự tạo chẩn đoán, tư vấn hay đơn
266
+ thuốc. Bác sĩ kiểm tra bản nháp trước khi sử dụng.
267
+ </p>
268
  </header>
269
+ <div className="trust-grid">
270
+ <article>
271
+ <h3>Nhận đúng tiếng Việt lâm sàng</h3>
272
+ <ul>
273
+ <li> hình nhận dạng giọng nói tiếng Việt, giữ nguyên dấu và chính tả.</li>
274
+ <li>
275
+ Thuật ngữ y khoa được đối chiếu với bộ từ điển tiếng Việt
276
+ tuyển chọn, nhận cả cách nói tắt và thiếu dấu.
277
+ </li>
278
+ <li>
279
+ Lời chép gốc, chỗ đã chỉnh và phần còn thiếu đều hiển thị rõ,
280
+ nên bác sĩ kiểm tra nhanh và đúng trọng tâm.
281
+ </li>
282
+ </ul>
283
+ </article>
284
+ <article>
285
+ <h3>Âm thanh của buổi khám không bị giữ lại</h3>
286
+ <ul>
287
+ <li>
288
+ Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu và bị xóa
289
+ ngay khi xử lý xong.
290
+ </li>
291
+ <li>Âm thanh buổi khám không bao giờ được dùng để huấn luyện mô hình.</li>
292
+ <li>Màn hình không tự bắt đầu micro; chỉ dùng tệp cơ sở chủ động tải lên.</li>
293
+ </ul>
294
+ </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  </div>
296
  </section>
297
 
298
+ <section className="start-section" id="start" aria-labelledby="start-title">
299
+ <div className="start-section__copy">
300
+ <h2 id="start-title">Bắt đầu từ một tệp âm thanh đã được phép sử dụng.</h2>
301
+ <p>
302
+ Công cụ sẽ chỉ cho bạn từng bước tải tệp, tạo bản nháp và kiểm tra
303
+ trước khi sử dụng.
304
+ </p>
305
+ <a className="button-link button-link--light" href={scribeHref}>
306
+ Bắt đầu ghi chép
307
+ </a>
308
  </div>
309
+ <details className="pilot-disclosure">
310
+ <summary>Dành cho cơ sở muốn thí điểm CarePath</summary>
311
+ <div>
312
+ <p>
313
+ Để lại bối cảnh làm việc nếu cơ sở muốn cùng xác định cách thử
314
+ nghiệm phù hợp. Biểu mẫu này chỉ dành cho Ghi chép bệnh án AI.
315
+ </p>
316
+ <LeadForm
317
+ clinic=""
318
+ specialty=""
319
+ scenario={scenarios[0]}
320
+ transcript=""
321
+ language="vi"
322
+ interest="scribe"
323
+ hideInterestField
324
+ />
325
+ </div>
326
+ </details>
327
  </section>
328
  </main>
329
 
 
332
  <img src={logoUrl} alt="" />
333
  <strong>{copy.footer.promise}</strong>
334
  </div>
 
335
  <p>{copy.footer.honesty}</p>
336
  <div>
337
  <a href={`mailto:${leadContact.email}`}>{copy.footer.contact}</a>
scribe/frontend/src/LeadForm.test.tsx CHANGED
@@ -182,6 +182,31 @@ describe("LeadForm", () => {
182
  expect(onInterestChange).toHaveBeenCalledWith("both");
183
  });
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  it("includes the simulation context only after explicit opt-in", async () => {
186
  const fetcher = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
187
  render(
 
182
  expect(onInterestChange).toHaveBeenCalledWith("both");
183
  });
184
 
185
+ it("submits a fixed Scribe interest when the landing selector is hidden", async () => {
186
+ const fetcher = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
187
+ render(
188
+ <LeadForm
189
+ clinic="Phòng khám Minh Tâm"
190
+ endpoint="https://leads.example.test/pilot"
191
+ fetcher={fetcher}
192
+ hideInterestField
193
+ interest="scribe"
194
+ language="vi"
195
+ scenario={scenarios[0]}
196
+ specialty="Nội tổng quát"
197
+ transcript=""
198
+ />,
199
+ );
200
+
201
+ expect(screen.queryByRole("combobox", { name: "Chức năng quan tâm" })).not.toBeInTheDocument();
202
+ fillRequiredFields();
203
+ fireEvent.click(screen.getByRole("button", { name: "Gửi yêu cầu thí điểm" }));
204
+
205
+ await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
206
+ const [, request] = (fetcher as ReturnType<typeof vi.fn>).mock.calls[0];
207
+ expect(JSON.parse(String(request.body)).interest).toBe("scribe");
208
+ });
209
+
210
  it("includes the simulation context only after explicit opt-in", async () => {
211
  const fetcher = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
212
  render(
scribe/frontend/src/LeadForm.tsx CHANGED
@@ -22,6 +22,7 @@ interface LeadFormProps {
22
  onMailto?: (url: string) => void;
23
  interest?: ProductInterest;
24
  onInterestChange?: (interest: ProductInterest) => void;
 
25
  }
26
 
27
  export default function LeadForm({
@@ -36,6 +37,7 @@ export default function LeadForm({
36
  onMailto,
37
  interest: controlledInterest,
38
  onInterestChange,
 
39
  }: LeadFormProps) {
40
  const labels = copyFor(language).form;
41
  const [localInterest, setLocalInterest] = useState<ProductInterest>("both");
@@ -161,25 +163,27 @@ export default function LeadForm({
161
  {labels.includeDemoContext}
162
  </label>
163
  )}
164
- <label>
165
- {labels.interest}
166
- <select
167
- value={interest}
168
- onChange={(event) => {
169
- const nextInterest = event.target.value as ProductInterest;
170
- if (controlledInterest === undefined) {
171
- setLocalInterest(nextInterest);
172
- }
173
- onInterestChange?.(nextInterest);
174
- }}
175
- >
176
- <option value="interpreter">
177
- {labels.interestOptions.interpreter}
178
- </option>
179
- <option value="scribe">{labels.interestOptions.scribe}</option>
180
- <option value="both">{labels.interestOptions.both}</option>
181
- </select>
182
- </label>
 
 
183
  <label>
184
  {labels.role}
185
  <input
 
22
  onMailto?: (url: string) => void;
23
  interest?: ProductInterest;
24
  onInterestChange?: (interest: ProductInterest) => void;
25
+ hideInterestField?: boolean;
26
  }
27
 
28
  export default function LeadForm({
 
37
  onMailto,
38
  interest: controlledInterest,
39
  onInterestChange,
40
+ hideInterestField = false,
41
  }: LeadFormProps) {
42
  const labels = copyFor(language).form;
43
  const [localInterest, setLocalInterest] = useState<ProductInterest>("both");
 
163
  {labels.includeDemoContext}
164
  </label>
165
  )}
166
+ {!hideInterestField && (
167
+ <label>
168
+ {labels.interest}
169
+ <select
170
+ value={interest}
171
+ onChange={(event) => {
172
+ const nextInterest = event.target.value as ProductInterest;
173
+ if (controlledInterest === undefined) {
174
+ setLocalInterest(nextInterest);
175
+ }
176
+ onInterestChange?.(nextInterest);
177
+ }}
178
+ >
179
+ <option value="interpreter">
180
+ {labels.interestOptions.interpreter}
181
+ </option>
182
+ <option value="scribe">{labels.interestOptions.scribe}</option>
183
+ <option value="both">{labels.interestOptions.both}</option>
184
+ </select>
185
+ </label>
186
+ )}
187
  <label>
188
  {labels.role}
189
  <input
scribe/frontend/src/content/strings.ts CHANGED
@@ -42,6 +42,68 @@ interface SafetyCardCopy {
42
  items: SafetyItem[];
43
  }
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  interface PageCopy {
46
  metadata: {
47
  title: string;
@@ -53,12 +115,13 @@ interface PageCopy {
53
  en: string;
54
  };
55
  nav: {
56
- interpreter: string;
57
- scribe: string;
58
  safety: string;
59
  pilot: string;
60
  menu: string;
61
  };
 
62
  demo: {
63
  brand: string;
64
  consoleCta: string;
@@ -148,7 +211,7 @@ interface PageCopy {
148
  title: string;
149
  helper: string;
150
  intro: string;
151
- preStartSteps: [string, string, string, string];
152
  preStartReminder: string;
153
  uploadNotice: string;
154
  continue: string;
@@ -221,8 +284,8 @@ interface PageCopy {
221
  export const strings: Record<Language, PageCopy> = {
222
  vi: {
223
  metadata: {
224
- title: "CarePath | Ghi chép bệnh án AI Phiên dịch khám bệnh trực tiếp",
225
- description: "CarePath hỗ trợ Ghi chép bệnh án AI Phiên dịch khám bệnh trực tiếp cho sở y tế.",
226
  },
227
  language: {
228
  label: "Ngôn ngữ",
@@ -230,12 +293,120 @@ export const strings: Record<Language, PageCopy> = {
230
  en: "EN",
231
  },
232
  nav: {
233
- interpreter: "Phiên dịch khám bệnh trực tiếp",
234
- scribe: "Ghi chép bệnh án AI",
235
- safety: "An toàn",
236
- pilot: "Thí điểm",
237
  menu: "Mở điều hướng",
238
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  demo: {
240
  brand: "CarePath Phiên dịch",
241
  consoleCta: "Mở công cụ Phiên dịch",
@@ -438,10 +609,9 @@ export const strings: Record<Language, PageCopy> = {
438
  helper: "Tạo bản nháp y khoa",
439
  intro: "AI dùng phiên âm tự động để tạo bản nháp y khoa theo bốn mục SOAP; bác sĩ cần kiểm tra trước khi sử dụng.",
440
  preStartSteps: [
441
- "Tải tệp ghi âm buổi khám lên",
442
- "Phiên âm tự động lời nói thành văn bản",
443
- "AI tạo bản nháp y khoa theo bốn mục SOAP",
444
- "B��c sĩ kiểm tra, chỉnh sửa; bản nháp không tự vào hồ sơ",
445
  ],
446
  preStartReminder: "AI chỉ hỗ trợ tạo bản nháp. Bác sĩ cần kiểm tra lại nội dung trước khi sử dụng.",
447
  uploadNotice: "Cơ sở chủ động chọn và tải tệp ghi âm lên để tạo bản nháp. Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu này; màn hình không tự bắt đầu micro.",
@@ -616,14 +786,14 @@ export const strings: Record<Language, PageCopy> = {
616
  footer: {
617
  promise: "AI hỗ trợ, bác sĩ giữ quyền quyết định.",
618
  posture: "Thiết kế theo hướng giảm thiểu dữ liệu, nhận biết yêu cầu của Nghị định 13/PDP và định vị phù hợp với nguyên tắc tiếp cận ngôn ngữ của §1557. Đây không phải tuyên bố chứng nhận pháp lý.",
619
- honesty: "Phiên dịch khám bệnh trực tiếp là bản mô phỏng tương tác; Ghi chép bệnh án AI công cụ thí điểm tạo bản nháp. Cả hai đều cần giám sát của nhân viên y tế.",
620
  contact: "Liên hệ chương trình thí điểm",
621
  },
622
  },
623
  en: {
624
  metadata: {
625
- title: "CarePath | Clinical note drafting and live medical interpretation",
626
- description: "CarePath supports clinical note drafting and live medical interpretation for care teams.",
627
  },
628
  language: {
629
  label: "Language",
@@ -631,12 +801,120 @@ export const strings: Record<Language, PageCopy> = {
631
  en: "EN",
632
  },
633
  nav: {
634
- interpreter: "Interpreter",
635
- scribe: "Scribe",
636
- safety: "Safety",
637
- pilot: "Pilot",
638
  menu: "Open navigation",
639
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  demo: {
641
  brand: "CarePath Interpreter",
642
  consoleCta: "Open the Interpreter tool",
@@ -839,10 +1117,9 @@ export const strings: Record<Language, PageCopy> = {
839
  helper: "AI Scribe",
840
  intro: "Upload a Vietnamese consultation recording. The system uses automated transcription, corrects medical terms, and creates a four-part SOAP draft for clinician review.",
841
  preStartSteps: [
842
- "Upload the visit recording",
843
- "Automated transcription turns speech into text",
844
- "AI creates a four-part SOAP draft",
845
- "The clinician checks and edits; the draft never enters the record automatically",
846
  ],
847
  preStartReminder: "AI assists with drafting only. The clinician must review the draft before use.",
848
  uploadNotice: "The clinic deliberately selects and uploads the recording file to create a draft. The file is processed temporarily for this request only; this screen does not start the microphone.",
@@ -1017,7 +1294,7 @@ export const strings: Record<Language, PageCopy> = {
1017
  footer: {
1018
  promise: "AI assistance, clinician in control.",
1019
  posture: "Designed around data minimization, awareness of Decree 13/PDP obligations, and §1557-aligned language-access positioning. This is not a legal certification claim.",
1020
- honesty: "Interpreter is an interactive simulation; Scribe is a pilot draft tool. Both require healthcare-staff oversight.",
1021
  contact: "Contact the pilot program",
1022
  },
1023
  },
 
42
  items: SafetyItem[];
43
  }
44
 
45
+ interface LandingCopy {
46
+ status: { title: string; state: string; body: string };
47
+ hero: {
48
+ title: string;
49
+ body: string;
50
+ primary: string;
51
+ secondary: string;
52
+ visual: {
53
+ sourceLabel: string;
54
+ sourceText: string;
55
+ transcriptLabel: string;
56
+ transcriptText: string;
57
+ draftLabel: string;
58
+ draftText: string;
59
+ };
60
+ };
61
+ process: { label: string; items: string[] };
62
+ problem: {
63
+ title: string;
64
+ body: string;
65
+ duringTitle: string;
66
+ duringBody: string;
67
+ afterTitle: string;
68
+ afterBody: string;
69
+ workflowTitle: string;
70
+ workflowBody: string;
71
+ reviewTitle: string;
72
+ reviewBody: string;
73
+ };
74
+ workflow: {
75
+ title: string;
76
+ body: string;
77
+ stages: Array<{ title: string; body: string }>;
78
+ };
79
+ story: {
80
+ titleStart: string;
81
+ titleEnd: string;
82
+ body: string;
83
+ rawTitle: string;
84
+ rawText: string;
85
+ rawDetail: string;
86
+ correctedTitle: string;
87
+ correctedText: string;
88
+ correctedDetail: string;
89
+ draftTitle: string;
90
+ draftDetail: string;
91
+ };
92
+ safety: {
93
+ title: string;
94
+ body: string;
95
+ items: Array<{ title: string; body: string }>;
96
+ };
97
+ action: {
98
+ title: string;
99
+ body: string;
100
+ primary: string;
101
+ pilotTitle: string;
102
+ pilotBody: string;
103
+ pilotNote: string;
104
+ };
105
+ }
106
+
107
  interface PageCopy {
108
  metadata: {
109
  title: string;
 
115
  en: string;
116
  };
117
  nav: {
118
+ need: string;
119
+ workflow: string;
120
  safety: string;
121
  pilot: string;
122
  menu: string;
123
  };
124
+ landing: LandingCopy;
125
  demo: {
126
  brand: string;
127
  consoleCta: string;
 
211
  title: string;
212
  helper: string;
213
  intro: string;
214
+ preStartSteps: [string, string, string];
215
  preStartReminder: string;
216
  uploadNotice: string;
217
  continue: string;
 
284
  export const strings: Record<Language, PageCopy> = {
285
  vi: {
286
  metadata: {
287
+ title: "CarePath | Bớt bệnh án, thêm thời gian cho người bệnh",
288
+ description: "CarePath chuẩn bị bản nháp ghi chú lâm sàng từ tệp âm thanh để bác sĩ kiểm tra. Phiên dịch trực tiếp đang phát triển chưa mở trên web.",
289
  },
290
  language: {
291
  label: "Ngôn ngữ",
 
293
  en: "EN",
294
  },
295
  nav: {
296
+ need: " sao cần",
297
+ workflow: "Cách hoạt động",
298
+ safety: "Bác sĩ kiểm tra",
299
+ pilot: "Dùng thử",
300
  menu: "Mở điều hướng",
301
  },
302
+ landing: {
303
+ status: {
304
+ title: "Phiên dịch khám bệnh trực tiếp",
305
+ state: "Đang phát triển — hiện chưa thể truy cập trên web.",
306
+ body: "CarePath đang hoàn thiện các luồng an toàn cần thiết trước khi mở lại chức năng này.",
307
+ },
308
+ hero: {
309
+ title: "Tập trung vào người bệnh. Để CarePath soạn bản nháp sau buổi khám.",
310
+ body: "Tải lên tệp âm thanh buổi khám đã được phép sử dụng. CarePath chép lời, chuẩn hóa thuật ngữ và tạo bản nháp bệnh án có cấu trúc để bác sĩ kiểm tra.",
311
+ primary: "Bắt đầu ghi chép",
312
+ secondary: "Xem cách hoạt động",
313
+ visual: {
314
+ sourceLabel: "Trong buổi khám",
315
+ sourceText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
316
+ transcriptLabel: "Lời chép được làm rõ",
317
+ transcriptText: "Đau đầu 3 ngày · buồn nôn nhẹ · không sốt",
318
+ draftLabel: "Bản nháp có cấu trúc",
319
+ draftText: "Triệu chứng và thông tin phủ định được đặt đúng mục để bác sĩ kiểm tra.",
320
+ },
321
+ },
322
+ process: {
323
+ label: "Tệp âm thanh, chép lời, chuẩn hóa thuật ngữ, bản nháp SOAP, bác sĩ kiểm tra",
324
+ items: [
325
+ "Tệp âm thanh",
326
+ "Chép lời tiếng Việt",
327
+ "Chuẩn hóa thuật ngữ",
328
+ "Bản nháp SOAP",
329
+ "Bác sĩ kiểm tra",
330
+ ],
331
+ },
332
+ problem: {
333
+ title: "Một buổi khám không nên biến thành hai ca làm việc.",
334
+ body: "Trong phòng khám, bác sĩ cần lắng nghe, đặt câu hỏi và đưa ra quyết định. Sau đó vẫn còn phần sắp xếp nội dung thành một ghi chú có cấu trúc.",
335
+ duringTitle: "Trong buổi khám",
336
+ duringBody: "Tập trung vào câu chuyện, triệu chứng và điều người bệnh đang cần.",
337
+ afterTitle: "Sau buổi khám",
338
+ afterBody: "Nhớ lại nội dung, gõ từng mục và kiểm tra xem thông tin quan trọng có bị bỏ sót hay không.",
339
+ workflowTitle: "Không bắt đầu từ trang trắng",
340
+ workflowBody: "CarePath biến lời đã nói thành một bản nháp có cấu trúc để bác sĩ bắt đầu từ việc kiểm tra, không phải chép lại.",
341
+ reviewTitle: "Bác sĩ quyết định bản cuối",
342
+ reviewBody: "CarePath không tự hoàn tất hồ sơ và không thay thế nhận định lâm sàng.",
343
+ },
344
+ workflow: {
345
+ title: "Một luồng rõ ràng từ tệp âm thanh đến bản nháp.",
346
+ body: "Mỗi bước đều giữ cho bác sĩ biết dữ liệu đến từ đâu và cần kiểm tra điều gì trước khi sử dụng.",
347
+ stages: [
348
+ {
349
+ title: "Tệp âm thanh đã được phép sử dụng",
350
+ body: "Cơ sở y tế chủ động chọn và tải lên tệp âm thanh của buổi khám.",
351
+ },
352
+ {
353
+ title: "Chép lời tiếng Việt",
354
+ body: "Nội dung đã nói được chuyển thành văn bản để bác sĩ có thể đọc lại.",
355
+ },
356
+ {
357
+ title: "Chuẩn hóa thuật ngữ",
358
+ body: "Tên thuốc và thuật ngữ y khoa được làm rõ nhưng vẫn phải được bác sĩ đối chiếu.",
359
+ },
360
+ {
361
+ title: "Tạo bản nháp có cấu trúc",
362
+ body: "Thông tin được sắp xếp thành các mục bệnh án, kể cả phần còn thiếu cần chú ý.",
363
+ },
364
+ {
365
+ title: "Bác sĩ kiểm tra",
366
+ body: "Chỉ bác sĩ mới quyết định nội dung nào được sửa, bổ sung hoặc sử dụng.",
367
+ },
368
+ ],
369
+ },
370
+ story: {
371
+ titleStart: "Từ lời đã nói",
372
+ titleEnd: "đến bản nháp bệnh án có cấu trúc.",
373
+ body: "Ba lớp thông tin được trình bày riêng để bác sĩ có thể đối chiếu nguồn, phần đã làm rõ và bản nháp cuối.",
374
+ rawTitle: "Lời chép ban đầu",
375
+ rawText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
376
+ rawDetail: "Giữ lại nội dung nguồn để bác sĩ đối chiếu.",
377
+ correctedTitle: "Thuật ngữ được làm rõ",
378
+ correctedText: "Đau đầu 3 ngày · buồn nôn nhẹ · không sốt",
379
+ correctedDetail: "Số, thuật ngữ và thông tin phủ định vẫn cần được kiểm tra.",
380
+ draftTitle: "Bản nháp SOAP",
381
+ draftDetail: "Thông tin được sắp xếp, không tự biến thành hồ sơ hoàn tất.",
382
+ },
383
+ safety: {
384
+ title: "Bản nháp chỉ được dùng sau khi bác sĩ kiểm tra.",
385
+ body: "CarePath hỗ trợ công việc ghi chép. Quyền quyết định lâm sàng và trách nhiệm duyệt nội dung luôn thuộc về bác sĩ.",
386
+ items: [
387
+ {
388
+ title: "Luôn hiển thị nội dung nguồn",
389
+ body: "Bác sĩ có thể so sánh lời chép, phần đã làm rõ và bản nháp.",
390
+ },
391
+ {
392
+ title: "Đánh dấu phần còn thiếu",
393
+ body: "Thông tin chưa có không được tự điền thành một kết luận chắc chắn.",
394
+ },
395
+ {
396
+ title: "Không tự đưa vào hồ sơ",
397
+ body: "Bản nháp cần được kiểm tra và xử lý theo quy trình của cơ sở y tế.",
398
+ },
399
+ ],
400
+ },
401
+ action: {
402
+ title: "Bắt đầu từ một tệp âm thanh đã được phép sử dụng.",
403
+ body: "Thử luồng ghi chép hiện có hoặc để lại thông tin nếu cơ sở của bạn muốn trao đổi về chương trình thí điểm.",
404
+ primary: "Bắt đầu ghi chép",
405
+ pilotTitle: "Trao đổi về chương trình thí điểm",
406
+ pilotBody: "Cho CarePath biết bối cảnh của cơ sở để cùng xác định cách thử nghiệm phù hợp.",
407
+ pilotNote: "Biểu mẫu này chỉ dành cho Ghi chép bệnh án AI.",
408
+ },
409
+ },
410
  demo: {
411
  brand: "CarePath Phiên dịch",
412
  consoleCta: "Mở công cụ Phiên dịch",
 
609
  helper: "Tạo bản nháp y khoa",
610
  intro: "AI dùng phiên âm tự động để tạo bản nháp y khoa theo bốn mục SOAP; bác sĩ cần kiểm tra trước khi sử dụng.",
611
  preStartSteps: [
612
+ "Chọn tệp âm thanh buổi khám đã được phép sử dụng",
613
+ "Thêm bối cảnh ngắn nếu cần rồi tạo bản nháp",
614
+ "Đối chiếu lời chép, sửa xác nhận trước khi sử dụng",
 
615
  ],
616
  preStartReminder: "AI chỉ hỗ trợ tạo bản nháp. Bác sĩ cần kiểm tra lại nội dung trước khi sử dụng.",
617
  uploadNotice: "Cơ sở chủ động chọn và tải tệp ghi âm lên để tạo bản nháp. Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu này; màn hình không tự bắt đầu micro.",
 
786
  footer: {
787
  promise: "AI hỗ trợ, bác sĩ giữ quyền quyết định.",
788
  posture: "Thiết kế theo hướng giảm thiểu dữ liệu, nhận biết yêu cầu của Nghị định 13/PDP và định vị phù hợp với nguyên tắc tiếp cận ngôn ngữ của §1557. Đây không phải tuyên bố chứng nhận pháp lý.",
789
+ honesty: "Ghi chép bệnh án AI tạo bản nháp cần bác kiểm tra. Phiên dịch khám bệnh trực tiếp đang phát triển chưa mở trên web.",
790
  contact: "Liên hệ chương trình thí điểm",
791
  },
792
  },
793
  en: {
794
  metadata: {
795
+ title: "CarePath | AI clinical documentation for Vietnamese doctors",
796
+ description: "CarePath prepares a structured clinical-note draft from permitted consultation audio for clinician review. Live interpretation remains in development and is not open on the web.",
797
  },
798
  language: {
799
  label: "Language",
 
801
  en: "EN",
802
  },
803
  nav: {
804
+ need: "Why it matters",
805
+ workflow: "How it works",
806
+ safety: "Clinician review",
807
+ pilot: "Try it",
808
  menu: "Open navigation",
809
  },
810
+ landing: {
811
+ status: {
812
+ title: "Live medical interpretation",
813
+ state: "In development — not currently accessible on the web.",
814
+ body: "CarePath is completing the required safety workflows before this product reopens.",
815
+ },
816
+ hero: {
817
+ title: "Focus on the patient. Let CarePath prepare the draft after the visit.",
818
+ body: "Upload permitted consultation audio. CarePath transcribes it, normalizes medical terms, and prepares a structured clinical-note draft for clinician review.",
819
+ primary: "Start documenting",
820
+ secondary: "See how it works",
821
+ visual: {
822
+ sourceLabel: "During the visit",
823
+ sourceText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
824
+ transcriptLabel: "Clarified transcript",
825
+ transcriptText: "Headache for 3 days · mild nausea · no fever",
826
+ draftLabel: "Structured draft",
827
+ draftText: "Symptoms and negative findings are placed in the right sections for clinician review.",
828
+ },
829
+ },
830
+ process: {
831
+ label: "Audio file, transcription, term normalization, SOAP draft, clinician review",
832
+ items: [
833
+ "Audio file",
834
+ "Vietnamese transcript",
835
+ "Term normalization",
836
+ "SOAP draft",
837
+ "Clinician review",
838
+ ],
839
+ },
840
+ problem: {
841
+ title: "One consultation should not become two shifts of work.",
842
+ body: "During the visit, clinicians need to listen, ask questions, and make decisions. Afterwards, the conversation still has to become a structured note.",
843
+ duringTitle: "During the visit",
844
+ duringBody: "Stay with the patient's story, symptoms, and immediate needs.",
845
+ afterTitle: "After the visit",
846
+ afterBody: "Recall the conversation, enter each section, and check whether important details were missed.",
847
+ workflowTitle: "Do not start from a blank page",
848
+ workflowBody: "CarePath turns spoken content into a structured draft so review begins with evidence instead of retyping.",
849
+ reviewTitle: "The clinician decides the final note",
850
+ reviewBody: "CarePath does not complete the record independently or replace clinical judgment.",
851
+ },
852
+ workflow: {
853
+ title: "A clear path from permitted audio to a reviewable draft.",
854
+ body: "Each step keeps the source visible and makes clear what the clinician must verify before use.",
855
+ stages: [
856
+ {
857
+ title: "Permitted consultation audio",
858
+ body: "The healthcare organization chooses and uploads the consultation audio file.",
859
+ },
860
+ {
861
+ title: "Vietnamese transcription",
862
+ body: "Spoken content becomes text that the clinician can read and compare.",
863
+ },
864
+ {
865
+ title: "Medical term normalization",
866
+ body: "Drug names and medical terms are clarified but still require clinician verification.",
867
+ },
868
+ {
869
+ title: "Structured draft",
870
+ body: "Information is organized into clinical-note sections, including missing details that need attention.",
871
+ },
872
+ {
873
+ title: "Clinician review",
874
+ body: "Only the clinician decides what to edit, add, or use.",
875
+ },
876
+ ],
877
+ },
878
+ story: {
879
+ titleStart: "From spoken detail",
880
+ titleEnd: "to a structured clinical-note draft.",
881
+ body: "Source, clarified content, and the resulting draft remain separate so the clinician can compare each layer.",
882
+ rawTitle: "Initial transcript",
883
+ rawText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
884
+ rawDetail: "The source remains visible for comparison.",
885
+ correctedTitle: "Clarified terminology",
886
+ correctedText: "Headache for 3 days · mild nausea · no fever",
887
+ correctedDetail: "Numbers, medical terms, and negative findings still require review.",
888
+ draftTitle: "SOAP draft",
889
+ draftDetail: "Information is organized without becoming a completed medical record.",
890
+ },
891
+ safety: {
892
+ title: "The draft is used only after clinician review.",
893
+ body: "CarePath supports documentation work. Clinical decisions and responsibility for the final content remain with the clinician.",
894
+ items: [
895
+ {
896
+ title: "Source content stays visible",
897
+ body: "The clinician can compare the transcript, clarified content, and draft.",
898
+ },
899
+ {
900
+ title: "Missing information is marked",
901
+ body: "Absent details are not silently turned into confident conclusions.",
902
+ },
903
+ {
904
+ title: "No automatic record entry",
905
+ body: "The draft must follow the healthcare organization's review process.",
906
+ },
907
+ ],
908
+ },
909
+ action: {
910
+ title: "Start with an audio file you are permitted to use.",
911
+ body: "Try the available documentation workflow or leave your details to discuss a pilot for your organization.",
912
+ primary: "Start documenting",
913
+ pilotTitle: "Discuss a pilot",
914
+ pilotBody: "Tell CarePath about your organization so we can identify an appropriate way to test the workflow.",
915
+ pilotNote: "This form is only for AI clinical documentation.",
916
+ },
917
+ },
918
  demo: {
919
  brand: "CarePath Interpreter",
920
  consoleCta: "Open the Interpreter tool",
 
1117
  helper: "AI Scribe",
1118
  intro: "Upload a Vietnamese consultation recording. The system uses automated transcription, corrects medical terms, and creates a four-part SOAP draft for clinician review.",
1119
  preStartSteps: [
1120
+ "Choose an approved consultation audio file",
1121
+ "Add brief context if needed and create the draft",
1122
+ "Review the transcript, edit, and confirm before use",
 
1123
  ],
1124
  preStartReminder: "AI assists with drafting only. The clinician must review the draft before use.",
1125
  uploadNotice: "The clinic deliberately selects and uploads the recording file to create a draft. The file is processed temporarily for this request only; this screen does not start the microphone.",
 
1294
  footer: {
1295
  promise: "AI assistance, clinician in control.",
1296
  posture: "Designed around data minimization, awareness of Decree 13/PDP obligations, and §1557-aligned language-access positioning. This is not a legal certification claim.",
1297
+ honesty: "AI clinical documentation produces a draft for clinician review. Live medical interpretation remains in development and is not open on the web.",
1298
  contact: "Contact the pilot program",
1299
  },
1300
  },
scribe/frontend/src/scribe/ScribeShowcase.test.tsx CHANGED
@@ -3,42 +3,22 @@ import { describe, expect, it } from "vitest";
3
  import ScribeShowcase from "./ScribeShowcase";
4
 
5
  describe("ScribeShowcase", () => {
6
- it("walks raw transcript, correction, and SOAP draft steps", () => {
7
- render(<ScribeShowcase language="vi" />);
8
 
9
- expect(screen.getByText("am đi pin")).toBeInTheDocument();
10
- expect(
11
- screen.getByText(
12
- "Gạch chân: đoạn hệ thống nghe sai trong bản phiên âm tự động.",
13
- ),
14
- ).toBeInTheDocument();
15
-
16
- fireEvent.click(
17
- screen.getByRole("button", { name: "2. Sau hiệu chỉnh thuật ngữ" }),
18
- );
19
- expect(screen.getByText("amlodipin")).toBeInTheDocument();
20
- expect(screen.getByText("160/90 mmHg")).toBeInTheDocument();
21
 
22
- fireEvent.click(
23
- screen.getByRole("button", {
24
- name: "3. Bản ghi y khoa theo bốn mục SOAP",
25
- }),
26
- );
27
- expect(screen.getByText("Chờ bác sĩ duyệt")).toBeInTheDocument();
28
  expect(screen.getByText("Kế hoạch")).toBeInTheDocument();
29
- expect(
30
- screen.getByText("Tăng huyết áp chưa kiểm soát."),
31
- ).toBeInTheDocument();
32
- });
33
-
34
- it("localizes the frame while keeping the Vietnamese sample", () => {
35
- render(<ScribeShowcase language="en" />);
36
 
 
 
37
  expect(
38
- screen.getByText("Simulation with sample data, not a real record"),
39
- ).toBeInTheDocument();
40
- fireEvent.click(screen.getByRole("button", { name: "3. SOAP draft" }));
41
- expect(screen.getByText("Awaiting clinician review")).toBeInTheDocument();
42
- expect(screen.getByText("Subjective")).toBeInTheDocument();
43
  });
44
  });
 
3
  import ScribeShowcase from "./ScribeShowcase";
4
 
5
  describe("ScribeShowcase", () => {
6
+ it("teaches the sample flow without inventing assessment or plan", () => {
7
+ render(<ScribeShowcase />);
8
 
9
+ expect(screen.getByText(/Tôi bị đau tức ngực thoáng qua/)).toBeInTheDocument();
10
+ fireEvent.click(screen.getByRole("button", { name: "2. Nội dung được làm rõ" }));
11
+ expect(screen.getByText("Chưa có kết quả điện tâm đồ")).toBeInTheDocument();
 
 
 
 
 
 
 
 
 
12
 
13
+ fireEvent.click(screen.getByRole("button", { name: "3. Bản nháp có cấu trúc" }));
14
+ expect(screen.getAllByText("Chờ bác sĩ nhập hoặc xác nhận.")).toHaveLength(2);
15
+ expect(screen.getByText("Nhận định")).toBeInTheDocument();
 
 
 
16
  expect(screen.getByText("Kế hoạch")).toBeInTheDocument();
 
 
 
 
 
 
 
17
 
18
+ fireEvent.click(screen.getByRole("button", { name: "4. Bác sĩ kiểm tra" }));
19
+ expect(screen.getByText("Trước khi sử dụng bản nháp")).toBeInTheDocument();
20
  expect(
21
+ screen.getByRole("link", { name: "Mở công cụ tải tệp âm thanh" }),
22
+ ).toHaveAttribute("href", "/ghi-chep-lam-sang/");
 
 
 
23
  });
24
  });
scribe/frontend/src/scribe/ScribeShowcase.tsx CHANGED
@@ -1,116 +1,97 @@
1
  import { useState } from "react";
2
- import { copyFor } from "../content/strings";
3
- import type { Language } from "../demo/types";
4
 
5
- type Step = "raw" | "corrected" | "soap";
6
 
7
- interface Segment {
8
- text: string;
9
- mark?: "miss" | "fix";
10
- }
11
-
12
- // Sanitized sample dictation for the simulation. Every SOAP line in this
13
- // fixed example is traceable to the sample content below.
14
- const rawSegments: Segment[] = [
15
- { text: "bệnh nhân nữ 54 tuổi tiền sử tăng huyết áp đang dùng " },
16
- { text: "am lô đi pin", mark: "miss" },
17
- { text: " " },
18
- { text: "5 mi li gam", mark: "miss" },
19
- { text: " mỗi sáng hôm nay đau đầu chóng mặt huyết áp tự đo tại nhà " },
20
- { text: "một sáu mươi trên chín mươi", mark: "miss" },
21
- {
22
- text: " không đau ngực không khó thở chẩn đoán tăng huyết áp chưa kiểm soát kế hoạch đo lại huyết áp tại phòng khám theo dõi huyết áp mỗi sáng tái khám sau một tuần",
23
- },
24
  ];
25
 
26
- const correctedSegments: Segment[] = [
27
- { text: "Bệnh nhân nữ 54 tuổi, tiền sử tăng huyết áp, đang dùng " },
28
- { text: "amlodipin", mark: "fix" },
29
- { text: " " },
30
- { text: "5 mg", mark: "fix" },
31
- { text: " mỗi sáng. Hôm nay đau đầu, chóng mặt; huyết áp tự đo tại nhà " },
32
- { text: "160/90 mmHg", mark: "fix" },
33
  {
34
- text: ". Không đau ngực, không khó thở. Chẩn đoán: tăng huyết áp chưa kiểm soát. Kế hoạch: đo lại huyết áp tại phòng khám, theo dõi huyết áp mỗi sáng, tái khám sau một tuần.",
 
 
35
  },
36
- ];
37
-
38
- export const soapDraft: Array<{ key: "s" | "o" | "a" | "p"; text: string }> = [
39
- { key: "s", text: "Đau đầu, chóng mặt từ sáng nay. Không đau ngực, không khó thở." },
40
  {
41
  key: "o",
42
- text: "Huyết áp tự đo tại nhà 160/90 mmHg. Tiền sử tăng huyết áp, đang dùng amlodipin 5 mg mỗi sáng.",
43
- },
44
- { key: "a", text: "Tăng huyết áp chưa kiểm soát." },
45
- {
46
- key: "p",
47
- text: "Đo lại huyết áp tại phòng khám. Theo dõi huyết áp tại nhà mỗi sáng; tái khám sau một tuần.",
48
  },
 
 
49
  ];
50
 
51
- function SampleText({ segments }: { segments: Segment[] }) {
52
- return (
53
- <p className="scribe-doc__text" lang="vi">
54
- {segments.map((segment, index) =>
55
- segment.mark ? (
56
- <mark
57
- className={`scribe-mark scribe-mark--${segment.mark}`}
58
- key={`${segment.text}-${index}`}
59
- >
60
- {segment.text}
61
- </mark>
62
- ) : (
63
- segment.text
64
- ),
65
- )}
66
- </p>
67
- );
68
- }
69
-
70
- export default function ScribeShowcase({ language }: { language: Language }) {
71
- const copy = copyFor(language).scribe;
72
- const [step, setStep] = useState<Step>("raw");
73
- const steps: Step[] = ["raw", "corrected", "soap"];
74
 
75
  return (
76
- <div className="scribe-panel">
77
  <div className="scribe-panel__header">
78
- <p className="demo__brand">{copy.brand}</p>
79
- <p className="demo__disclosure">{copy.disclosure}</p>
80
  </div>
81
- <div className="scribe-tabs">
82
- {steps.map((value) => (
83
  <button
84
- aria-pressed={step === value}
 
85
  className="scribe-tab"
86
- key={value}
87
- onClick={() => setStep(value)}
88
  type="button"
89
  >
90
- {copy.steps[value].label}
91
  </button>
92
  ))}
93
  </div>
94
- <div className="scribe-doc">
95
- {step === "soap" ? (
 
 
 
 
 
 
 
 
96
  <>
97
- <p className="scribe-status">{copy.status}</p>
 
 
 
 
 
 
 
 
 
98
  <dl className="soap-note">
99
  {soapDraft.map((row) => (
100
  <div key={row.key}>
101
- <dt>
102
- <span aria-hidden="true">{row.key.toUpperCase()}</span>
103
- {copy.soapLabels[row.key]}
104
- </dt>
105
  <dd lang="vi">{row.text}</dd>
106
  </div>
107
  ))}
108
  </dl>
 
109
  </>
110
- ) : (
111
- <SampleText segments={step === "raw" ? rawSegments : correctedSegments} />
112
  )}
113
- <p className="scribe-caption">{copy.steps[step].caption}</p>
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
  </div>
116
  );
 
1
  import { useState } from "react";
 
 
2
 
3
+ type Step = "conversation" | "clarified" | "draft" | "review";
4
 
5
+ const steps: Array<{ key: Step; label: string }> = [
6
+ { key: "conversation", label: "1. Cuộc trao đổi" },
7
+ { key: "clarified", label: "2. Nội dung được làm rõ" },
8
+ { key: "draft", label: "3. Bản nháp có cấu trúc" },
9
+ { key: "review", label: "4. Bác sĩ kiểm tra" },
 
 
 
 
 
 
 
 
 
 
 
 
10
  ];
11
 
12
+ export const soapDraft: Array<{ key: "s" | "o" | "a" | "p"; label: string; text: string }> = [
 
 
 
 
 
 
13
  {
14
+ key: "s",
15
+ label: "Triệu chứng chủ quan",
16
+ text: "Đau tức ngực thoáng qua từ sáng, khó thở nhẹ. Chưa dùng thuốc.",
17
  },
 
 
 
 
18
  {
19
  key: "o",
20
+ label: "Thông tin khách quan",
21
+ text: "Chưa có kết quả khám hoặc cận lâm sàng trong nội dung ghi âm.",
 
 
 
 
22
  },
23
+ { key: "a", label: "Nhận định", text: "Chờ bác sĩ nhập hoặc xác nhận." },
24
+ { key: "p", label: "Kế hoạch", text: "Chờ bác sĩ nhập hoặc xác nhận." },
25
  ];
26
 
27
+ export default function ScribeShowcase() {
28
+ const [step, setStep] = useState<Step>("conversation");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  return (
31
+ <div className="scribe-panel guided-sample">
32
  <div className="scribe-panel__header">
33
+ <p className="demo__brand">Ca khám mẫu đã làm sạch</p>
34
+ <p className="demo__disclosure">Mô phỏng — không phải bệnh án thật</p>
35
  </div>
36
+ <div className="scribe-tabs" aria-label="Các bước của ca khám mẫu">
37
+ {steps.map(({ key, label }) => (
38
  <button
39
+ aria-controls="guided-sample-panel"
40
+ aria-pressed={step === key}
41
  className="scribe-tab"
42
+ key={key}
43
+ onClick={() => setStep(key)}
44
  type="button"
45
  >
46
+ {label}
47
  </button>
48
  ))}
49
  </div>
50
+ <div className="scribe-doc" id="guided-sample-panel">
51
+ {step === "conversation" && (
52
+ <>
53
+ <p className="sample-speaker"><strong>Bệnh nhân:</strong> Tôi bị đau tức ngực thoáng qua từ sáng, hơi khó thở. Tôi chưa dùng thuốc.</p>
54
+ <p className="sample-speaker"><strong>Bác sĩ:</strong> Anh đã có kết quả điện tâm đồ hoặc xét nghiệm nào chưa?</p>
55
+ <p className="sample-speaker"><strong>Bệnh nhân:</strong> Chưa có.</p>
56
+ <p className="scribe-caption">CarePath chỉ bắt đầu từ điều đã được nói trong cuộc trao đổi.</p>
57
+ </>
58
+ )}
59
+ {step === "clarified" && (
60
  <>
61
+ <p className="scribe-doc__text" lang="vi">
62
+ Bệnh nhân đau tức ngực thoáng qua từ sáng, khó thở nhẹ. Chưa dùng
63
+ thuốc. <mark className="scribe-mark scribe-mark--fix">Chưa có kết quả điện tâm đồ</mark> hoặc xét nghiệm trong nội dung ghi âm.
64
+ </p>
65
+ <p className="scribe-caption">Thông tin phủ định và thuật ngữ được làm rõ nhưng vẫn cần bác sĩ đối chiếu với âm thanh.</p>
66
+ </>
67
+ )}
68
+ {step === "draft" && (
69
+ <>
70
+ <p className="scribe-status">Bản nháp — cần bác sĩ kiểm tra</p>
71
  <dl className="soap-note">
72
  {soapDraft.map((row) => (
73
  <div key={row.key}>
74
+ <dt><span aria-hidden="true">{row.key.toUpperCase()}</span>{row.label}</dt>
 
 
 
75
  <dd lang="vi">{row.text}</dd>
76
  </div>
77
  ))}
78
  </dl>
79
+ <p className="scribe-caption">CarePath không tự điền nhận định hay kế hoạch khi cuộc trao đổi chưa cung cấp.</p>
80
  </>
 
 
81
  )}
82
+ {step === "review" && (
83
+ <div className="review-checklist">
84
+ <h3>Trước khi sử dụng bản nháp</h3>
85
+ <ul>
86
+ <li>Đối chiếu triệu chứng, thời điểm và thông tin phủ định với âm thanh.</li>
87
+ <li>Kiểm tra phần còn thiếu hoặc chưa chắc chắn.</li>
88
+ <li>Tự nhập hoặc xác nhận Nhận định và Kế hoạch.</li>
89
+ </ul>
90
+ <a className="button-link button-link--primary" href="/ghi-chep-lam-sang/">
91
+ Mở công cụ và tải tệp âm thanh
92
+ </a>
93
+ </div>
94
+ )}
95
  </div>
96
  </div>
97
  );
scribe/frontend/src/scribe/ScribeTool.test.tsx CHANGED
@@ -51,7 +51,7 @@ describe("ScribeTool", () => {
51
  .mockResolvedValueOnce(soapResponse());
52
  vi.stubGlobal("fetch", fetchMock);
53
 
54
- render(<ScribeTool language="vi" />);
55
  await screen.findByText("Chế độ demo");
56
  expect(
57
  screen.getAllByRole("link", { name: "Tất cả chức năng" }),
@@ -62,23 +62,10 @@ describe("ScribeTool", () => {
62
  expect(
63
  screen.getByRole("heading", { name: "Ghi chép bệnh án AI — bản nháp" }),
64
  ).toBeInTheDocument();
65
- expect(
66
- screen.getByText(
67
- "AI chỉ hỗ trợ tạo bản nháp. Bác sĩ cần kiểm tra lại nội dung trước khi sử dụng.",
68
- ),
69
- ).toBeInTheDocument();
70
- expect(screen.getByText("Tải tệp ghi âm buổi khám lên")).toBeInTheDocument();
71
- expect(
72
- screen.getByText(
73
- "AI tạo bản nháp y khoa theo bốn mục SOAP",
74
- ),
75
- ).toBeInTheDocument();
76
- expect(
77
- screen.getByText(
78
- "Bác sĩ kiểm tra, chỉnh sửa; bản nháp không tự vào hồ sơ",
79
- ),
80
- ).toBeInTheDocument();
81
- fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
82
 
83
  const submit = screen.getByRole("button", { name: "Tạo bản nháp SOAP" });
84
  expect(submit).toBeDisabled();
@@ -126,8 +113,7 @@ describe("ScribeTool", () => {
126
  });
127
  vi.stubGlobal("fetch", fetchMock);
128
 
129
- render(<ScribeTool language="vi" />);
130
- fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
131
  selectWav();
132
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
133
 
@@ -155,8 +141,7 @@ describe("ScribeTool", () => {
155
  }),
156
  );
157
 
158
- render(<ScribeTool language="vi" />);
159
- fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
160
  selectWav();
161
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
162
 
@@ -173,8 +158,7 @@ describe("ScribeTool", () => {
173
  .mockRejectedValueOnce(new TypeError("Failed to fetch")),
174
  );
175
 
176
- render(<ScribeTool language="vi" />);
177
- fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
178
  selectWav();
179
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
180
 
@@ -185,8 +169,8 @@ describe("ScribeTool", () => {
185
 
186
  it("marks the backend unreachable when health fails", async () => {
187
  vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
188
- render(<ScribeTool language="en" />);
189
- expect(await screen.findByText("Server unreachable")).toBeInTheDocument();
190
  });
191
  });
192
 
@@ -225,8 +209,7 @@ describe("classifyFailure", () => {
225
  }),
226
  );
227
 
228
- render(<ScribeTool language="vi" />);
229
- fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
230
  selectWav();
231
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
232
 
 
51
  .mockResolvedValueOnce(soapResponse());
52
  vi.stubGlobal("fetch", fetchMock);
53
 
54
+ render(<ScribeTool />);
55
  await screen.findByText("Chế độ demo");
56
  expect(
57
  screen.getAllByRole("link", { name: "Tất cả chức năng" }),
 
62
  expect(
63
  screen.getByRole("heading", { name: "Ghi chép bệnh án AI — bản nháp" }),
64
  ).toBeInTheDocument();
65
+ expect(screen.getByText("Ba bước để tạo bản nháp")).toBeInTheDocument();
66
+ expect(screen.getByText("Chọn tệp âm thanh buổi khám đã được phép sử dụng")).toBeInTheDocument();
67
+ expect(screen.getByRole("link", { name: "Xem ca khám mẫu trước" })).toHaveAttribute("href", "/#demo");
68
+ expect(screen.queryByRole("button", { name: "Tiếp tục tạo bản nháp" })).not.toBeInTheDocument();
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  const submit = screen.getByRole("button", { name: "Tạo bản nháp SOAP" });
71
  expect(submit).toBeDisabled();
 
113
  });
114
  vi.stubGlobal("fetch", fetchMock);
115
 
116
+ render(<ScribeTool />);
 
117
  selectWav();
118
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
119
 
 
141
  }),
142
  );
143
 
144
+ render(<ScribeTool />);
 
145
  selectWav();
146
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
147
 
 
158
  .mockRejectedValueOnce(new TypeError("Failed to fetch")),
159
  );
160
 
161
+ render(<ScribeTool />);
 
162
  selectWav();
163
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
164
 
 
169
 
170
  it("marks the backend unreachable when health fails", async () => {
171
  vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
172
+ render(<ScribeTool />);
173
+ expect(await screen.findByText("Mất kết nối máy chủ")).toBeInTheDocument();
174
  });
175
  });
176
 
 
209
  }),
210
  );
211
 
212
+ render(<ScribeTool />);
 
213
  selectWav();
214
  fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
215
 
scribe/frontend/src/scribe/ScribeTool.tsx CHANGED
@@ -9,7 +9,6 @@ import {
9
  } from "react";
10
  import logoUrl from "../assets/carepath.svg";
11
  import { copyFor } from "../content/strings";
12
- import type { Language } from "../demo/types";
13
 
14
  // Same-origin by default: the combined API serves this site and /api/v1/*.
15
  const API_BASE: string = import.meta.env.VITE_API_BASE ?? "";
@@ -109,19 +108,14 @@ function formatBytes(size: number): string {
109
 
110
  interface ScribeToolProps {
111
  backHref?: string;
112
- language: Language;
113
- onLanguageChange?: (language: Language) => void;
114
  }
115
 
116
  export default function ScribeTool({
117
  backHref = "#top",
118
- language,
119
- onLanguageChange,
120
  }: ScribeToolProps) {
121
- const copy = copyFor(language);
122
  const labels = copy.scribeTool;
123
  const [state, setState] = useState<ToolState>("idle");
124
- const [readyToRecord, setReadyToRecord] = useState(false);
125
  const [health, setHealth] = useState<HealthState>("checking");
126
  const [file, setFile] = useState<File | null>(null);
127
  const [context, setContext] = useState("");
@@ -253,7 +247,7 @@ export default function ScribeTool({
253
 
254
  return (
255
  <div className="site-shell">
256
- <nav className="site-nav" aria-label={language === "vi" ? "Điều hướng chính" : "Main navigation"}>
257
  <a className="site-nav__brand" href={backHref} aria-label={labels.back}>
258
  <img src={logoUrl} alt="" />
259
  <span className="product-breadcrumb">
@@ -264,26 +258,8 @@ export default function ScribeTool({
264
  </a>
265
  <div className="site-nav__actions">
266
  <span className="product-shell-status">
267
- {language === "vi"
268
- ? "Công cụ thí điểm · Cần bác sĩ duyệt"
269
- : "Pilot tool · Clinician review required"}
270
  </span>
271
- <div className="language-toggle" aria-label={copy.language.label}>
272
- <button
273
- aria-pressed={language === "vi"}
274
- onClick={() => onLanguageChange?.("vi")}
275
- type="button"
276
- >
277
- {copy.language.vi}
278
- </button>
279
- <button
280
- aria-pressed={language === "en"}
281
- onClick={() => onLanguageChange?.("en")}
282
- type="button"
283
- >
284
- {copy.language.en}
285
- </button>
286
- </div>
287
  <a className="nav-cta" href={backHref}>{labels.back}</a>
288
  </div>
289
  </nav>
@@ -299,26 +275,21 @@ export default function ScribeTool({
299
  </p>
300
  </header>
301
 
302
- {state === "idle" && !readyToRecord && (
303
- <section className="scribe-panel tool-panel tool-prestart" aria-label={labels.title}>
304
- <ol className="progress-rail progress-rail--tool">
 
 
305
  {labels.preStartSteps.map((step, index) => (
306
  <li key={step}>
307
  <span aria-hidden="true">{index + 1}</span>
308
  {step}
309
  </li>
310
  ))}
311
- </ol>
312
- <p className="tool-disclaimer">{labels.preStartReminder}</p>
313
- <p className="tool-disclaimer">{labels.uploadNotice}</p>
314
- <button className="button button--primary" onClick={() => setReadyToRecord(true)} type="button">
315
- {labels.continue}
316
- </button>
317
- </section>
318
- )}
319
-
320
- {state === "idle" && readyToRecord && (
321
- <form className="scribe-panel tool-panel" onSubmit={submit}>
322
  <label
323
  className={dragging ? "dropzone is-drag" : "dropzone"}
324
  onDragEnter={(event) => {
 
9
  } from "react";
10
  import logoUrl from "../assets/carepath.svg";
11
  import { copyFor } from "../content/strings";
 
12
 
13
  // Same-origin by default: the combined API serves this site and /api/v1/*.
14
  const API_BASE: string = import.meta.env.VITE_API_BASE ?? "";
 
108
 
109
  interface ScribeToolProps {
110
  backHref?: string;
 
 
111
  }
112
 
113
  export default function ScribeTool({
114
  backHref = "#top",
 
 
115
  }: ScribeToolProps) {
116
+ const copy = copyFor("vi");
117
  const labels = copy.scribeTool;
118
  const [state, setState] = useState<ToolState>("idle");
 
119
  const [health, setHealth] = useState<HealthState>("checking");
120
  const [file, setFile] = useState<File | null>(null);
121
  const [context, setContext] = useState("");
 
247
 
248
  return (
249
  <div className="site-shell">
250
+ <nav className="site-nav" aria-label="Điều hướng chính">
251
  <a className="site-nav__brand" href={backHref} aria-label={labels.back}>
252
  <img src={logoUrl} alt="" />
253
  <span className="product-breadcrumb">
 
258
  </a>
259
  <div className="site-nav__actions">
260
  <span className="product-shell-status">
261
+ Công cụ thí điểm · Cần bác sĩ duyệt
 
 
262
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  <a className="nav-cta" href={backHref}>{labels.back}</a>
264
  </div>
265
  </nav>
 
275
  </p>
276
  </header>
277
 
278
+ {state === "idle" && (
279
+ <form className="scribe-panel tool-panel" onSubmit={submit}>
280
+ <div className="tool-start-guide">
281
+ <h2>Ba bước để tạo bản nháp</h2>
282
+ <ol>
283
  {labels.preStartSteps.map((step, index) => (
284
  <li key={step}>
285
  <span aria-hidden="true">{index + 1}</span>
286
  {step}
287
  </li>
288
  ))}
289
+ </ol>
290
+ <p>{labels.uploadNotice}</p>
291
+ <a href="/#demo">Xem ca khám mẫu trước</a>
292
+ </div>
 
 
 
 
 
 
 
293
  <label
294
  className={dragging ? "dropzone is-drag" : "dropzone"}
295
  onDragEnter={(event) => {
scribe/frontend/src/styles.css CHANGED
@@ -115,7 +115,6 @@ textarea {
115
  .button:focus-visible,
116
  .button-link:focus-visible,
117
  .scenario:focus-visible,
118
- .language-toggle button:focus-visible,
119
  .nav-cta:focus-visible,
120
  a:focus-visible,
121
  input:focus-visible,
@@ -183,30 +182,6 @@ textarea:focus-visible {
183
  gap: 0.7rem;
184
  }
185
 
186
- .language-toggle {
187
- display: flex;
188
- overflow: hidden;
189
- border: 1px solid var(--line-strong);
190
- border-radius: 999px;
191
- }
192
-
193
- .language-toggle button {
194
- min-width: 2.4rem;
195
- min-height: 2.4rem;
196
- padding: 0;
197
- border: 0;
198
- background: transparent;
199
- color: var(--text);
200
- cursor: pointer;
201
- font-size: 0.7rem;
202
- font-weight: 700;
203
- }
204
-
205
- .language-toggle button[aria-pressed="true"] {
206
- background: var(--text);
207
- color: var(--bg);
208
- }
209
-
210
  .nav-cta {
211
  padding: 0.6rem 1rem;
212
  border-radius: var(--r-control);
@@ -1295,7 +1270,7 @@ mark.scribe-mark {
1295
 
1296
  .site-footer {
1297
  display: grid;
1298
- grid-template-columns: 1fr 1.5fr 1fr auto;
1299
  gap: clamp(1.5rem, 4vw, 4rem);
1300
  align-items: start;
1301
  margin-top: var(--section-pad);
@@ -1460,7 +1435,7 @@ mark.scribe-mark {
1460
 
1461
  }
1462
 
1463
- /* ---------- two-product landing ---------- */
1464
 
1465
  .site-nav__menu {
1466
  display: none;
@@ -1492,614 +1467,540 @@ mark.scribe-mark {
1492
  white-space: nowrap;
1493
  }
1494
 
1495
- .hero {
1496
- position: relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1497
  width: min(100% - 3rem, var(--container));
1498
- min-height: min(50rem, calc(100svh - 4rem));
1499
  margin: 0 auto;
1500
- padding: clamp(5rem, 10vw, 9rem) 0 clamp(4rem, 8vw, 7rem);
1501
  }
1502
 
1503
- .hero-title.hero-title--wide {
1504
- width: 100%;
 
 
 
1505
  max-width: 72rem;
1506
  margin: 0;
1507
- font-size: clamp(2rem, 6.6vw, 5.8rem);
1508
  font-weight: 700;
1509
  letter-spacing: -0.065em;
1510
  line-height: 0.98;
1511
  text-wrap: balance;
1512
  }
1513
 
1514
- .hero__lower {
1515
  display: grid;
1516
- grid-template-columns: minmax(0, 0.9fr) minmax(22rem, 1.1fr);
1517
- gap: clamp(2rem, 7vw, 7rem);
1518
- align-items: end;
1519
- margin-top: clamp(3rem, 7vw, 6rem);
1520
  }
1521
 
1522
- .hero__copy {
1523
- max-width: 42rem;
 
 
 
 
1524
  }
1525
 
1526
- .hero__body {
1527
- max-width: 40rem;
1528
- margin: 0;
 
1529
  color: var(--muted);
1530
- font-size: clamp(1rem, 1.4vw, 1.2rem);
1531
- line-height: 1.75;
1532
  }
1533
 
1534
- .hero__actions {
1535
  display: flex;
1536
  flex-wrap: wrap;
1537
  gap: 0.75rem;
1538
- margin-top: 1.8rem;
1539
- }
1540
-
1541
- .button-link {
1542
- transition:
1543
- transform 220ms ease,
1544
- background 220ms ease,
1545
- border-color 220ms ease;
1546
  }
1547
 
1548
- .button-link:hover {
1549
- transform: translateY(-2px);
 
 
1550
  }
1551
 
1552
- .button-link--interpreter {
1553
- border-color: var(--teal);
1554
- background: var(--teal);
1555
- color: white;
 
 
 
1556
  }
1557
 
1558
- .button-link--interpreter:hover {
1559
- border-color: color-mix(in srgb, var(--teal), black 16%);
1560
- background: color-mix(in srgb, var(--teal), black 16%);
1561
  }
1562
 
1563
- .button-link--scribe {
1564
- border-color: var(--amber);
1565
- background: var(--amber);
1566
- color: white;
 
 
 
 
1567
  }
1568
 
1569
- .button-link--scribe:hover {
1570
- border-color: color-mix(in srgb, var(--amber), black 14%);
1571
- background: color-mix(in srgb, var(--amber), black 14%);
 
 
 
 
1572
  }
1573
 
1574
- .hero__art {
1575
- position: relative;
1576
- min-height: 18rem;
1577
- border: 1px solid var(--line);
1578
- background:
1579
- linear-gradient(135deg, color-mix(in srgb, var(--teal) 18%, transparent), transparent 54%),
1580
- linear-gradient(315deg, color-mix(in srgb, var(--amber) 18%, transparent), transparent 56%),
1581
- var(--surface);
1582
- box-shadow: var(--shadow);
1583
  }
1584
 
1585
- .hero__art::before {
1586
- position: absolute;
1587
- inset: 12% 18%;
1588
- border: 1px solid var(--line);
1589
- border-radius: 50%;
1590
- content: "";
1591
- transform: rotate(-12deg);
1592
  }
1593
 
1594
- .hero__route {
1595
- position: absolute;
1596
  display: grid;
1597
- grid-template-columns: auto 1fr auto;
1598
- gap: 0.8rem;
1599
- align-items: center;
1600
- width: min(82%, 28rem);
1601
- padding: 1rem 1.1rem;
1602
- border: 1px solid var(--line-strong);
1603
- background: var(--surface);
1604
- box-shadow: 0 1rem 2.5rem rgb(16 42 46 / 0.1);
1605
  }
1606
 
1607
- .hero__route span,
1608
- .hero__route strong {
1609
- font-size: 0.78rem;
 
1610
  }
1611
 
1612
- .hero__route span {
1613
- color: var(--muted);
 
 
1614
  }
1615
 
1616
- .hero__route i {
1617
- height: 1px;
1618
- background: currentColor;
 
 
 
 
1619
  }
1620
 
1621
- .hero__route--interpreter {
1622
- top: 22%;
1623
- left: -4%;
1624
- color: var(--teal);
1625
- transform: rotate(-3deg);
1626
  }
1627
 
1628
- .hero__route--scribe {
1629
- right: -3%;
1630
- bottom: 20%;
1631
- color: var(--amber-text);
1632
- transform: rotate(2.5deg);
1633
  }
1634
 
1635
- .section-intro {
1636
- max-width: 52rem;
1637
  }
1638
 
1639
- .section-intro--wide {
1640
- max-width: 64rem;
1641
  }
1642
 
1643
- .section-intro h1,
1644
- .section-intro h2 {
 
1645
  margin: 0;
1646
- font-size: clamp(2rem, 4.5vw, 4rem);
1647
- font-weight: 650;
1648
- letter-spacing: -0.045em;
1649
- line-height: 1.02;
1650
  text-wrap: balance;
1651
  }
1652
 
1653
- .section-intro > p {
 
 
1654
  max-width: 46rem;
1655
- margin: 1.25rem 0 0;
1656
  color: var(--muted);
1657
- line-height: 1.75;
1658
- }
1659
-
1660
- .product-gateway {
1661
- width: min(100% - 3rem, var(--container));
1662
- margin: 0 auto;
1663
- padding: clamp(3rem, 7vw, 6rem) 0;
1664
- scroll-margin-top: 5rem;
1665
  }
1666
 
1667
- .product-accordion {
1668
  display: grid;
1669
- grid-template-columns: minmax(0, 1.35fr) minmax(18rem, 0.85fr);
1670
- margin-top: clamp(2.5rem, 5vw, 4rem);
 
1671
  border: 1px solid var(--line);
1672
  background: var(--line);
1673
- gap: 1px;
1674
- overflow: hidden;
1675
  }
1676
 
1677
- .product-accordion__panel {
1678
- display: grid;
1679
- grid-template-rows: auto auto auto 1fr auto;
1680
- gap: 1rem;
1681
- min-width: 0;
1682
  padding: clamp(1.5rem, 3.5vw, 3rem);
1683
- color: var(--text);
1684
- text-decoration: none;
1685
- }
1686
-
1687
- .product-accordion__panel--interpreter {
1688
- background:
1689
- linear-gradient(160deg, color-mix(in srgb, var(--teal) 9%, transparent), transparent 58%),
1690
- var(--surface);
1691
- }
1692
-
1693
- .product-accordion__panel--scribe {
1694
- background:
1695
- linear-gradient(160deg, color-mix(in srgb, var(--amber) 20%, transparent), transparent 58%),
1696
- var(--surface);
1697
- }
1698
-
1699
- .product-accordion__panel--scribe .product-accordion__heading h2 {
1700
- font-size: clamp(2rem, 4vw, 3.5rem);
1701
- }
1702
-
1703
- .product-accordion__panel--scribe .product-accordion__status {
1704
- color: var(--amber-text);
1705
- }
1706
-
1707
- .product-accordion__heading {
1708
- display: grid;
1709
- gap: 0.65rem;
1710
  }
1711
 
1712
- .product-accordion__heading h2 {
 
 
 
 
1713
  margin: 0;
1714
- font-size: clamp(1.6rem, 3vw, 2.6rem);
1715
- letter-spacing: -0.04em;
1716
- line-height: 1;
1717
  }
1718
 
1719
- .product-accordion__helper,
1720
- .product-accordion__status {
1721
- max-width: 24rem;
1722
  color: var(--muted);
1723
- font-size: 0.8rem;
1724
- font-weight: 600;
1725
- }
1726
-
1727
- .product-accordion__status {
1728
- color: var(--accent-ink);
1729
- }
1730
-
1731
- .product-accordion__panel > p {
1732
- max-width: 34rem;
1733
- margin: 0;
1734
- color: var(--muted);
1735
- line-height: 1.65;
1736
  }
1737
 
1738
- .product-accordion dl {
1739
  display: grid;
 
1740
  gap: 1rem;
1741
- margin: 0;
1742
  }
1743
 
1744
- .product-accordion dl > div {
1745
  display: grid;
1746
- grid-template-columns: 5.5rem 1fr;
1747
- gap: 1rem;
1748
- padding-top: 0.9rem;
1749
- border-top: 1px solid var(--line);
 
1750
  }
1751
 
1752
- .product-accordion dt {
1753
- color: var(--muted);
1754
- font-size: 0.72rem;
1755
- font-weight: 700;
1756
- text-transform: uppercase;
 
 
 
 
1757
  }
1758
 
1759
- .product-accordion dd {
1760
- margin: 0;
1761
- font-size: 0.9rem;
1762
- line-height: 1.55;
 
 
 
1763
  }
1764
 
1765
- .product-accordion__cta {
1766
- display: flex;
1767
- align-items: center;
1768
- gap: 0.6rem;
1769
- align-self: end;
1770
- font-size: 0.88rem;
1771
  }
1772
 
1773
- .product-accordion__cta span {
1774
- display: inline-block;
1775
- margin-left: 0.35rem;
1776
- transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
1777
  }
1778
 
1779
- .product-accordion__panel:hover .product-accordion__cta span {
1780
- transform: translate(0.25rem, 0.25rem);
 
 
1781
  }
1782
 
1783
- .trust-strip {
1784
  display: grid;
1785
- grid-template-columns: repeat(3, minmax(0, 1fr));
1786
- width: min(100% - 3rem, var(--container));
1787
- margin: 0 auto;
1788
- border: 1px solid var(--line);
1789
- background: var(--line);
1790
- gap: 1px;
1791
  }
1792
 
1793
- .trust-strip article {
1794
- padding: clamp(1.25rem, 2.5vw, 2rem);
1795
- background: var(--surface);
1796
  }
1797
 
1798
- .trust-strip h2 {
1799
- margin: 0;
1800
- font-size: 1rem;
1801
  }
1802
 
1803
- .trust-strip p {
 
1804
  margin: 0.65rem 0 0;
1805
  color: var(--muted);
1806
- font-size: 0.84rem;
1807
- line-height: 1.6;
1808
  }
1809
 
1810
- .safety-suite {
1811
- width: min(100% - 2rem, calc(var(--container) + 4rem));
1812
- margin: clamp(8rem, 14vw, 13rem) auto 0;
1813
- padding: clamp(3rem, 7vw, 6rem);
1814
- scroll-margin-top: 5rem;
1815
- background:
1816
- radial-gradient(circle at 12% 8%, rgb(102 216 204 / 0.16), transparent 32rem),
1817
- var(--deep);
1818
- color: var(--on-deep);
1819
  }
1820
 
1821
- .section-intro--on-dark > p {
1822
- color: var(--on-deep-muted);
1823
  }
1824
 
1825
- .safety-bento {
1826
- display: grid;
1827
- grid-auto-flow: dense;
1828
- grid-template-columns: repeat(12, minmax(0, 1fr));
1829
- grid-template-rows: repeat(2, minmax(0, auto));
1830
- gap: 1px;
1831
- margin-top: clamp(3rem, 6vw, 5rem);
1832
- border: 1px solid rgb(244 241 232 / 0.18);
1833
- background: rgb(244 241 232 / 0.18);
1834
- isolation: isolate;
1835
- overflow: visible;
1836
  }
1837
 
1838
- .safety-bento__card {
1839
- display: grid;
1840
- grid-template-columns: minmax(11rem, 0.55fr) minmax(0, 1fr);
1841
- gap: clamp(1.5rem, 4vw, 4rem);
1842
- min-height: 22rem;
1843
- padding: clamp(1.5rem, 3.5vw, 3rem);
1844
- background: var(--deep);
1845
  }
1846
 
1847
- .safety-bento__card--shared {
1848
- grid-column: span 12;
1849
- background: color-mix(in srgb, var(--teal) 30%, var(--deep));
 
 
 
1850
  }
1851
 
1852
- .safety-bento__card--interpreter,
1853
- .safety-bento__card--scribe {
1854
- grid-column: span 6;
1855
- grid-template-columns: 1fr;
1856
  }
1857
 
1858
- .safety-bento__card--scribe {
1859
- background: color-mix(in srgb, var(--amber) 20%, var(--deep));
 
 
 
 
 
1860
  }
1861
 
1862
- .safety-bento__card header h3 {
1863
- margin: 0;
1864
- font-size: clamp(1.5rem, 2.6vw, 2.4rem);
1865
- letter-spacing: -0.04em;
1866
- line-height: 1.05;
1867
  }
1868
 
1869
- .safety-bento__card header p {
1870
  margin: 0.9rem 0 0;
1871
- color: var(--on-deep-muted);
1872
- font-size: 0.88rem;
1873
- line-height: 1.65;
1874
  }
1875
 
1876
- .safety-bento__card ul {
1877
- display: grid;
1878
- grid-template-columns: repeat(2, minmax(0, 1fr));
1879
- gap: 1.2rem;
1880
- margin: 0;
1881
- padding: 0;
1882
- list-style: none;
1883
  }
1884
 
1885
- .safety-bento__card li {
1886
  display: grid;
1887
- align-content: start;
1888
- gap: 0.45rem;
1889
- padding-top: 0.9rem;
1890
- border-top: 1px solid rgb(244 241 232 / 0.2);
1891
  }
1892
 
1893
- .safety-bento__card li strong {
1894
- font-size: 0.88rem;
1895
  }
1896
 
1897
- .safety-bento__card li span {
1898
- color: var(--on-deep-muted);
1899
- font-size: 0.78rem;
1900
- line-height: 1.55;
1901
- }
1902
-
1903
- .evidence {
1904
- display: grid;
1905
- grid-template-columns: minmax(16rem, 0.55fr) minmax(0, 1fr);
1906
- gap: clamp(3rem, 8vw, 8rem);
1907
- width: min(100% - 3rem, var(--container));
1908
  margin: 0 auto;
1909
- padding: clamp(8rem, 14vw, 13rem) 0;
1910
- }
1911
-
1912
- .evidence .section-intro h2 {
1913
- font-size: clamp(1.85rem, 2.2vw, 2.5rem);
1914
- }
1915
-
1916
- .evidence .section-intro a {
1917
- display: inline-block;
1918
- margin-top: 1rem;
1919
- color: var(--text);
1920
- font-size: 0.84rem;
1921
- font-weight: 650;
1922
- text-underline-offset: 0.25rem;
1923
  }
1924
 
1925
- .evidence-proof-grid {
1926
- display: grid;
1927
- gap: 1rem;
1928
- min-width: 0;
1929
- }
1930
-
1931
- .evidence-proof {
1932
- display: grid;
1933
- grid-template-columns: minmax(13rem, 0.65fr) minmax(0, 1fr);
1934
  border: 1px solid var(--line);
1935
  background: var(--surface);
1936
- overflow: hidden;
1937
  }
1938
 
1939
- .evidence-proof__copy {
1940
- display: grid;
1941
- align-content: start;
1942
- padding: clamp(1.5rem, 3.5vw, 3rem);
1943
  }
1944
 
1945
- .evidence-proof__copy > p:first-child {
1946
- margin: 0 0 2.5rem;
1947
- color: var(--accent-ink);
1948
- font-size: 0.75rem;
1949
- font-weight: 700;
 
 
1950
  }
1951
 
1952
- .evidence-proof--scribe .evidence-proof__copy > p:first-child,
1953
- .evidence-proof--research .evidence-proof__copy > p:first-child {
1954
- color: var(--amber-text);
1955
  }
1956
 
1957
- .evidence-proof__copy h3 {
1958
- margin: 0;
1959
- font-size: clamp(1.4rem, 1.8vw, 1.9rem);
1960
- letter-spacing: -0.04em;
1961
- line-height: 1.05;
1962
  }
1963
 
1964
- .evidence-proof__copy > p:last-of-type {
1965
- margin: 1.2rem 0 0;
1966
  color: var(--muted);
1967
  font-size: 0.88rem;
1968
- line-height: 1.7;
1969
  }
1970
 
1971
- .evidence-capture {
1972
  display: grid;
1973
- align-content: center;
1974
- padding: clamp(1.5rem, 4vw, 3.5rem);
1975
- background:
1976
- radial-gradient(circle at 80% 15%, color-mix(in srgb, var(--teal) 24%, transparent), transparent 16rem),
1977
- var(--tint-section);
 
1978
  }
1979
 
1980
- .evidence-proof--scribe .evidence-capture,
1981
- .evidence-proof--research .evidence-capture {
1982
- background:
1983
- radial-gradient(circle at 80% 15%, color-mix(in srgb, var(--amber) 20%, transparent), transparent 16rem),
1984
- var(--tint-section);
1985
  }
1986
 
1987
- .evidence-capture__bar,
1988
- .evidence-capture__content {
1989
- border: 1px solid var(--line-strong);
1990
  background: var(--surface);
 
1991
  }
1992
 
1993
- .evidence-capture__bar {
1994
- display: flex;
1995
- justify-content: space-between;
1996
- gap: 1rem;
1997
- padding: 0.8rem 1rem;
1998
- border-bottom: 0;
1999
- color: var(--muted);
2000
- font-size: 0.7rem;
2001
- font-weight: 650;
2002
- }
2003
-
2004
- .evidence-capture__content {
2005
- display: grid;
2006
- gap: 1rem;
2007
- min-height: 17rem;
2008
- align-content: center;
2009
- padding: clamp(1.25rem, 3vw, 2.5rem);
2010
  }
2011
 
2012
- .evidence-capture__turn,
2013
- .evidence-capture__content dl > div {
2014
- padding: 0.85rem 1rem;
2015
- border: 1px solid var(--line);
2016
- background: var(--tint-section);
2017
  }
2018
 
2019
- .product-accordion__timing,
2020
- .product-accordion__safety {
2021
- margin: 0;
2022
  font-size: 0.86rem;
2023
- line-height: 1.5;
2024
  }
2025
 
2026
- .product-accordion__timing {
2027
- color: var(--text);
2028
- font-weight: 700;
2029
- }
2030
-
2031
- .product-accordion__safety {
2032
- padding-top: 0.9rem;
2033
- border-top: 1px solid var(--line);
2034
- color: var(--muted);
2035
  }
2036
 
2037
- .evidence-capture__turn small,
2038
- .evidence-capture__content dt,
2039
- .evidence-capture__source {
2040
- color: var(--muted);
2041
- font-size: 0.7rem;
2042
- font-weight: 650;
2043
- line-height: 1.5;
2044
  }
2045
 
2046
- .evidence-capture__turn p,
2047
- .evidence-capture__content dd {
2048
- margin: 0.35rem 0 0;
2049
- font-size: 0.78rem;
2050
- line-height: 1.55;
2051
  }
2052
 
2053
- .evidence-capture__content dl {
2054
  display: grid;
 
2055
  gap: 0.75rem;
2056
- margin: 0;
 
 
2057
  }
2058
 
2059
- .evidence-capture__content dt {
2060
- color: var(--amber-text);
 
 
 
 
 
2061
  }
2062
 
2063
- .evidence-capture__source {
2064
- margin: 0;
 
 
 
 
 
 
 
2065
  }
2066
 
2067
- .evidence-capture__content strong {
2068
- padding: 1rem;
2069
- border-left: 3px solid var(--teal);
2070
- background: var(--tint);
2071
- font-size: 0.8rem;
2072
  line-height: 1.55;
2073
  }
2074
 
2075
- .evidence-proof--scribe .evidence-capture__content strong,
2076
- .evidence-proof--research .evidence-capture__content strong {
2077
- border-color: var(--amber);
2078
- }
2079
-
2080
- .site-nav__menu summary:focus-visible {
2081
- outline: 3px solid color-mix(in srgb, var(--teal), white 30%);
2082
- outline-offset: 3px;
2083
- }
2084
-
2085
- .pilot {
2086
- padding-block: clamp(8rem, 14vw, 12rem);
2087
- }
2088
-
2089
- .lead-form select {
2090
- width: 100%;
2091
- min-height: 2.75rem;
2092
- padding: 0.65rem 0.75rem;
2093
- border: 1px solid var(--line-strong);
2094
- border-radius: var(--r-control);
2095
- background: var(--input-bg);
2096
- color: var(--text);
2097
  }
2098
 
2099
  @media (max-width: 1023px) {
2100
  .site-nav__menu {
2101
  display: block;
2102
- order: 2;
2103
  }
2104
 
2105
  .site-nav__menu summary {
@@ -2142,28 +2043,19 @@ mark.scribe-mark {
2142
  border-bottom: 0;
2143
  }
2144
 
2145
- .site-nav__actions {
2146
- order: 3;
2147
- }
2148
-
2149
- .evidence {
2150
  grid-template-columns: 1fr;
2151
  }
2152
 
2153
- .evidence .section-intro {
2154
- max-width: 46rem;
2155
  }
2156
  }
2157
 
2158
  @media (max-width: 760px) {
2159
- .site-nav {
2160
- gap: 0.6rem;
2161
- }
2162
-
2163
- .site-nav__menu > div {
2164
- right: 1rem;
2165
- }
2166
-
2167
  .site-nav__brand > span:not(.product-breadcrumb) {
2168
  display: none;
2169
  }
@@ -2173,10 +2065,7 @@ mark.scribe-mark {
2173
  display: flex;
2174
  }
2175
 
2176
- .product-breadcrumb strong {
2177
- display: none;
2178
- }
2179
-
2180
  .product-shell-status {
2181
  display: none;
2182
  }
@@ -2200,54 +2089,70 @@ mark.scribe-mark {
2200
  transform: translate(-50%, -50%);
2201
  }
2202
 
2203
- .product-gateway,
2204
- .evidence {
2205
- width: min(100% - 1.5rem, var(--container));
2206
  }
2207
 
2208
- .product-accordion {
2209
- grid-template-columns: 1fr;
2210
  }
2211
 
2212
- .trust-strip {
2213
- grid-template-columns: 1fr;
 
 
2214
  width: min(100% - 1.5rem, var(--container));
2215
  }
2216
 
2217
- .evidence-proof {
2218
- grid-template-columns: 1fr;
2219
  }
2220
 
2221
- .safety-suite {
2222
- width: calc(100% - 1rem);
2223
- padding: 2.25rem 1.25rem;
 
2224
  }
2225
 
2226
- .safety-bento {
2227
- grid-template-columns: 1fr;
2228
- grid-template-rows: none;
2229
  }
2230
 
2231
- .safety-bento__card--shared,
2232
- .safety-bento__card--interpreter,
2233
- .safety-bento__card--scribe {
2234
- grid-column: span 1;
2235
  }
2236
 
2237
- .safety-bento__card,
2238
- .safety-bento__card--shared {
2239
  grid-template-columns: 1fr;
 
2240
  }
2241
 
2242
- .safety-bento__card ul {
2243
- grid-template-columns: 1fr;
 
 
2244
  }
2245
 
2246
- .evidence-slide {
2247
- min-height: 0;
2248
  }
2249
 
2250
- .evidence-capture {
2251
- min-height: 24rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2252
  }
2253
  }
 
115
  .button:focus-visible,
116
  .button-link:focus-visible,
117
  .scenario:focus-visible,
 
118
  .nav-cta:focus-visible,
119
  a:focus-visible,
120
  input:focus-visible,
 
182
  gap: 0.7rem;
183
  }
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  .nav-cta {
186
  padding: 0.6rem 1rem;
187
  border-radius: var(--r-control);
 
1270
 
1271
  .site-footer {
1272
  display: grid;
1273
+ grid-template-columns: 1fr 1.5fr auto;
1274
  gap: clamp(1.5rem, 4vw, 4rem);
1275
  align-items: start;
1276
  margin-top: var(--section-pad);
 
1435
 
1436
  }
1437
 
1438
+ /* ---------- CP-UX-11 compact Vietnamese onboarding ---------- */
1439
 
1440
  .site-nav__menu {
1441
  display: none;
 
1467
  white-space: nowrap;
1468
  }
1469
 
1470
+ .landing-main {
1471
+ overflow-x: clip;
1472
+ width: 100%;
1473
+ max-width: 100%;
1474
+ }
1475
+
1476
+ .interpreter-status {
1477
+ display: flex;
1478
+ flex-wrap: wrap;
1479
+ gap: 0.35rem 1rem;
1480
+ align-items: baseline;
1481
+ width: min(100% - 3rem, var(--container));
1482
+ margin: 1.25rem auto 0;
1483
+ padding: 0.75rem 1.1rem;
1484
+ border: 1px solid var(--line);
1485
+ background: var(--surface);
1486
+ }
1487
+
1488
+ .interpreter-status strong {
1489
+ font-size: 0.8rem;
1490
+ }
1491
+
1492
+ .interpreter-status span {
1493
+ color: var(--muted);
1494
+ font-size: 0.8rem;
1495
+ font-weight: 500;
1496
+ }
1497
+
1498
+ .button-link--primary {
1499
+ border-color: var(--deep);
1500
+ background: var(--deep);
1501
+ color: var(--on-deep);
1502
+ }
1503
+
1504
+ .button-link--primary:hover {
1505
+ background: color-mix(in srgb, var(--deep), black 16%);
1506
+ }
1507
+
1508
+ .button-link--light {
1509
+ margin-top: 1.6rem;
1510
+ border-color: var(--on-deep);
1511
+ background: var(--on-deep);
1512
+ color: var(--deep);
1513
+ }
1514
+
1515
+ .onboarding-hero,
1516
+ .burden-section,
1517
+ .demo-section,
1518
+ .evidence-section {
1519
  width: min(100% - 3rem, var(--container));
 
1520
  margin: 0 auto;
1521
+ scroll-margin-top: 5rem;
1522
  }
1523
 
1524
+ .onboarding-hero {
1525
+ padding: clamp(4rem, 7vw, 6rem) 0 clamp(4rem, 7vw, 6rem);
1526
+ }
1527
+
1528
+ .onboarding-hero > h1 {
1529
  max-width: 72rem;
1530
  margin: 0;
1531
+ font-size: clamp(2.7rem, 5.4vw, 4.9rem);
1532
  font-weight: 700;
1533
  letter-spacing: -0.065em;
1534
  line-height: 0.98;
1535
  text-wrap: balance;
1536
  }
1537
 
1538
+ .onboarding-hero__body {
1539
  display: grid;
1540
+ grid-template-columns: minmax(20rem, 1fr) minmax(24rem, 1fr);
1541
+ gap: clamp(2.5rem, 5vw, 5rem);
1542
+ align-items: start;
1543
+ margin-top: clamp(2.25rem, 4.5vw, 3.75rem);
1544
  }
1545
 
1546
+ .onboarding-hero__copy > p {
1547
+ max-width: 34rem;
1548
+ margin: 0;
1549
+ color: var(--muted);
1550
+ font-size: clamp(1.02rem, 1.6vw, 1.22rem);
1551
+ line-height: 1.65;
1552
  }
1553
 
1554
+ .onboarding-hero__copy > small {
1555
+ display: block;
1556
+ max-width: 34rem;
1557
+ margin-top: 1.1rem;
1558
  color: var(--muted);
1559
+ font-size: 0.78rem;
1560
+ line-height: 1.55;
1561
  }
1562
 
1563
+ .onboarding-hero__actions {
1564
  display: flex;
1565
  flex-wrap: wrap;
1566
  gap: 0.75rem;
1567
+ margin-top: 1.7rem;
 
 
 
 
 
 
 
1568
  }
1569
 
1570
+ .hero-proof {
1571
+ border: 1px solid var(--line);
1572
+ background: var(--surface);
1573
+ box-shadow: var(--shadow);
1574
  }
1575
 
1576
+ .hero-proof__title {
1577
+ margin: 0;
1578
+ padding: 0.8rem 1.1rem;
1579
+ border-bottom: 1px solid var(--line);
1580
+ color: var(--accent-ink);
1581
+ font-size: 0.75rem;
1582
+ font-weight: 700;
1583
  }
1584
 
1585
+ .hero-proof__stage {
1586
+ padding: 1rem 1.1rem;
 
1587
  }
1588
 
1589
+ .hero-proof__stage > span {
1590
+ display: block;
1591
+ margin-bottom: 0.5rem;
1592
+ color: var(--muted);
1593
+ font-size: 0.72rem;
1594
+ font-weight: 650;
1595
+ letter-spacing: 0.04em;
1596
+ text-transform: uppercase;
1597
  }
1598
 
1599
+ .hero-proof__stage > p {
1600
+ margin: 0;
1601
+ padding: 0.7rem 0.85rem;
1602
+ border-left: 3px solid var(--teal);
1603
+ background: var(--surface-2);
1604
+ font-size: 0.88rem;
1605
+ line-height: 1.6;
1606
  }
1607
 
1608
+ .hero-proof__stage--draft {
1609
+ border-top: 1px solid var(--line);
 
 
 
 
 
 
 
1610
  }
1611
 
1612
+ .hero-proof__stage--draft > span {
1613
+ color: var(--accent-ink);
 
 
 
 
 
1614
  }
1615
 
1616
+ .hero-proof__stage dl {
 
1617
  display: grid;
1618
+ gap: 0.75rem;
1619
+ margin: 0;
 
 
 
 
 
 
1620
  }
1621
 
1622
+ .hero-proof__stage dt {
1623
+ font-size: 0.72rem;
1624
+ font-weight: 700;
1625
+ color: var(--muted);
1626
  }
1627
 
1628
+ .hero-proof__stage dd {
1629
+ margin: 0.15rem 0 0;
1630
+ font-size: 0.88rem;
1631
+ line-height: 1.55;
1632
  }
1633
 
1634
+ .hero-proof__caption {
1635
+ margin: 0;
1636
+ padding: 0.8rem 1.1rem;
1637
+ border-top: 1px solid var(--line);
1638
+ color: var(--muted);
1639
+ font-size: 0.75rem;
1640
+ line-height: 1.55;
1641
  }
1642
 
1643
+ .burden-section,
1644
+ .demo-section,
1645
+ .evidence-section {
1646
+ padding: clamp(3.5rem, 6vw, 5.5rem) 0;
 
1647
  }
1648
 
1649
+ .burden-section {
1650
+ border-top: 1px solid var(--line);
 
 
 
1651
  }
1652
 
1653
+ .compact-heading {
1654
+ max-width: 50rem;
1655
  }
1656
 
1657
+ .compact-heading--wide {
1658
+ max-width: 61rem;
1659
  }
1660
 
1661
+ .compact-heading h2,
1662
+ .trust-section h2,
1663
+ .start-section h2 {
1664
  margin: 0;
1665
+ font-size: clamp(2rem, 4.2vw, 3.9rem);
1666
+ font-weight: 680;
1667
+ letter-spacing: -0.055em;
1668
+ line-height: 1.04;
1669
  text-wrap: balance;
1670
  }
1671
 
1672
+ .compact-heading > p,
1673
+ .trust-section > header > p,
1674
+ .start-section__copy > p {
1675
  max-width: 46rem;
1676
+ margin: 1.1rem 0 0;
1677
  color: var(--muted);
1678
+ line-height: 1.7;
 
 
 
 
 
 
 
1679
  }
1680
 
1681
+ .burden-layout {
1682
  display: grid;
1683
+ grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.85fr);
1684
+ gap: 1px;
1685
+ margin-top: 2.5rem;
1686
  border: 1px solid var(--line);
1687
  background: var(--line);
 
 
1688
  }
1689
 
1690
+ .burden-calculator,
1691
+ .workflow-comparison {
 
 
 
1692
  padding: clamp(1.5rem, 3.5vw, 3rem);
1693
+ background: var(--surface);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1694
  }
1695
 
1696
+ .burden-calculator h3,
1697
+ .workflow-comparison h3,
1698
+ .evidence-grid h3,
1699
+ .trust-grid h3,
1700
+ .review-checklist h3 {
1701
  margin: 0;
1702
+ font-size: 1.05rem;
 
 
1703
  }
1704
 
1705
+ .burden-calculator > p {
1706
+ margin: 0.55rem 0 0;
 
1707
  color: var(--muted);
1708
+ font-size: 0.86rem;
 
 
 
 
 
 
 
 
 
 
 
 
1709
  }
1710
 
1711
+ .calculator-fields {
1712
  display: grid;
1713
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1714
  gap: 1rem;
1715
+ margin-top: 1.5rem;
1716
  }
1717
 
1718
+ .calculator-fields label {
1719
  display: grid;
1720
+ gap: 0.45rem;
1721
+ min-width: 0;
1722
+ color: var(--muted);
1723
+ font-size: 0.78rem;
1724
+ font-weight: 650;
1725
  }
1726
 
1727
+ .calculator-fields input,
1728
+ .calculator-fields select {
1729
+ width: 100%;
1730
+ min-height: 2.9rem;
1731
+ padding: 0.65rem 0.75rem;
1732
+ border: 1px solid var(--line-strong);
1733
+ border-radius: var(--r-control);
1734
+ background: var(--input-bg);
1735
+ color: var(--text);
1736
  }
1737
 
1738
+ .calculator-result {
1739
+ display: grid;
1740
+ gap: 0.3rem;
1741
+ margin-top: 1.5rem;
1742
+ padding: 1.25rem;
1743
+ background: var(--deep);
1744
+ color: var(--on-deep);
1745
  }
1746
 
1747
+ .calculator-result span,
1748
+ .calculator-result small {
1749
+ color: var(--on-deep-muted);
1750
+ font-size: 0.76rem;
 
 
1751
  }
1752
 
1753
+ .calculator-result strong {
1754
+ font-size: clamp(1.55rem, 3vw, 2.35rem);
1755
+ letter-spacing: -0.04em;
1756
+ line-height: 1.1;
1757
  }
1758
 
1759
+ .burden-calculator .calculator-formula {
1760
+ margin-top: 0.9rem;
1761
+ font-size: 0.75rem;
1762
+ line-height: 1.55;
1763
  }
1764
 
1765
+ .workflow-comparison {
1766
  display: grid;
1767
+ align-content: start;
1768
+ gap: 0;
 
 
 
 
1769
  }
1770
 
1771
+ .workflow-comparison article {
1772
+ padding: 1.4rem 0;
1773
+ border-bottom: 1px solid var(--line);
1774
  }
1775
 
1776
+ .workflow-comparison article:first-child {
1777
+ padding-top: 0;
 
1778
  }
1779
 
1780
+ .workflow-comparison article p,
1781
+ .workflow-comparison > p {
1782
  margin: 0.65rem 0 0;
1783
  color: var(--muted);
1784
+ font-size: 0.9rem;
1785
+ line-height: 1.65;
1786
  }
1787
 
1788
+ .workflow-comparison > p {
1789
+ padding-top: 1.4rem;
1790
+ color: var(--accent-ink);
1791
+ font-weight: 650;
 
 
 
 
 
1792
  }
1793
 
1794
+ .guided-sample {
1795
+ margin-top: 2.5rem;
1796
  }
1797
 
1798
+ .guided-sample .scribe-tabs {
1799
+ grid-template-columns: repeat(4, minmax(0, 1fr));
 
 
 
 
 
 
 
 
 
1800
  }
1801
 
1802
+ .sample-speaker {
1803
+ max-width: 50rem;
1804
+ margin: 0;
1805
+ padding: 0.9rem 1rem;
1806
+ border-left: 3px solid var(--teal);
1807
+ background: var(--surface);
1808
+ line-height: 1.65;
1809
  }
1810
 
1811
+ .review-checklist ul {
1812
+ display: grid;
1813
+ gap: 0.65rem;
1814
+ max-width: 48rem;
1815
+ margin: 1rem 0 1.4rem;
1816
+ padding-left: 1.25rem;
1817
  }
1818
 
1819
+ .evidence-section {
1820
+ border-top: 1px solid var(--line);
 
 
1821
  }
1822
 
1823
+ .evidence-grid {
1824
+ display: grid;
1825
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1826
+ gap: 1px;
1827
+ margin-top: 2.5rem;
1828
+ border: 1px solid var(--line);
1829
+ background: var(--line);
1830
  }
1831
 
1832
+ .evidence-grid article {
1833
+ padding: clamp(1.4rem, 3vw, 2.4rem);
1834
+ background: var(--surface);
 
 
1835
  }
1836
 
1837
+ .evidence-grid p {
1838
  margin: 0.9rem 0 0;
1839
+ color: var(--muted);
1840
+ font-size: 0.86rem;
1841
+ line-height: 1.7;
1842
  }
1843
 
1844
+ .evidence-grid a {
1845
+ display: inline-block;
1846
+ margin-top: 1rem;
1847
+ color: var(--accent-ink);
1848
+ font-size: 0.8rem;
1849
+ font-weight: 700;
1850
+ text-underline-offset: 0.24rem;
1851
  }
1852
 
1853
+ .evidence-links {
1854
  display: grid;
1855
+ justify-items: start;
 
 
 
1856
  }
1857
 
1858
+ .evidence-links a + a {
1859
+ margin-top: 0.45rem;
1860
  }
1861
 
1862
+ .trust-section,
1863
+ .start-section {
1864
+ width: min(100% - 2rem, calc(var(--container) + 3rem));
 
 
 
 
 
 
 
 
1865
  margin: 0 auto;
1866
+ padding: clamp(2.5rem, 6vw, 5rem);
 
 
 
 
 
 
 
 
 
 
 
 
 
1867
  }
1868
 
1869
+ .trust-section {
 
 
 
 
 
 
 
 
1870
  border: 1px solid var(--line);
1871
  background: var(--surface);
 
1872
  }
1873
 
1874
+ .trust-section > header {
1875
+ max-width: 50rem;
 
 
1876
  }
1877
 
1878
+ .trust-grid {
1879
+ display: grid;
1880
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1881
+ gap: 1px;
1882
+ margin-top: 2rem;
1883
+ border: 1px solid var(--line);
1884
+ background: var(--line);
1885
  }
1886
 
1887
+ .trust-grid article {
1888
+ padding: clamp(1.4rem, 3vw, 2rem);
1889
+ background: var(--surface-2);
1890
  }
1891
 
1892
+ .trust-grid ul {
1893
+ display: grid;
1894
+ gap: 0.75rem;
1895
+ margin: 1rem 0 0;
1896
+ padding-left: 1.1rem;
1897
  }
1898
 
1899
+ .trust-grid li {
 
1900
  color: var(--muted);
1901
  font-size: 0.88rem;
1902
+ line-height: 1.6;
1903
  }
1904
 
1905
+ .start-section {
1906
  display: grid;
1907
+ grid-template-columns: minmax(18rem, 0.8fr) minmax(0, 1.2fr);
1908
+ gap: clamp(2.5rem, 6vw, 6rem);
1909
+ margin-top: 1.25rem;
1910
+ scroll-margin-top: 5rem;
1911
+ background: var(--deep);
1912
+ color: var(--on-deep);
1913
  }
1914
 
1915
+ .start-section__copy > p {
1916
+ color: var(--on-deep-muted);
 
 
 
1917
  }
1918
 
1919
+ .pilot-disclosure {
1920
+ align-self: start;
 
1921
  background: var(--surface);
1922
+ color: var(--text);
1923
  }
1924
 
1925
+ .pilot-disclosure > summary {
1926
+ padding: 1.2rem 1.35rem;
1927
+ cursor: pointer;
1928
+ font-weight: 700;
 
 
 
 
 
 
 
 
 
 
 
 
 
1929
  }
1930
 
1931
+ .pilot-disclosure > div {
1932
+ padding: 0 1.35rem 1.35rem;
 
 
 
1933
  }
1934
 
1935
+ .pilot-disclosure > div > p {
1936
+ margin: 0 0 1rem;
1937
+ color: var(--muted);
1938
  font-size: 0.86rem;
 
1939
  }
1940
 
1941
+ .pilot-disclosure .lead-form {
1942
+ padding: 0;
1943
+ border: 0;
1944
+ box-shadow: none;
 
 
 
 
 
1945
  }
1946
 
1947
+ .tool-start-guide {
1948
+ padding-bottom: 1.2rem;
1949
+ border-bottom: 1px solid var(--line);
 
 
 
 
1950
  }
1951
 
1952
+ .tool-start-guide h2 {
1953
+ margin: 0;
1954
+ font-size: 1rem;
 
 
1955
  }
1956
 
1957
+ .tool-start-guide ol {
1958
  display: grid;
1959
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1960
  gap: 0.75rem;
1961
+ margin: 1rem 0 0;
1962
+ padding: 0;
1963
+ list-style: none;
1964
  }
1965
 
1966
+ .tool-start-guide li {
1967
+ display: grid;
1968
+ grid-template-columns: 1.6rem minmax(0, 1fr);
1969
+ gap: 0.55rem;
1970
+ align-items: start;
1971
+ font-size: 0.78rem;
1972
+ line-height: 1.5;
1973
  }
1974
 
1975
+ .tool-start-guide li > span {
1976
+ display: grid;
1977
+ width: 1.6rem;
1978
+ height: 1.6rem;
1979
+ place-items: center;
1980
+ border-radius: 50%;
1981
+ background: var(--text);
1982
+ color: var(--bg);
1983
+ font-weight: 700;
1984
  }
1985
 
1986
+ .tool-start-guide p {
1987
+ margin: 1rem 0 0;
1988
+ color: var(--muted);
1989
+ font-size: 0.78rem;
 
1990
  line-height: 1.55;
1991
  }
1992
 
1993
+ .tool-start-guide > a {
1994
+ display: inline-block;
1995
+ margin-top: 0.7rem;
1996
+ color: var(--accent-ink);
1997
+ font-size: 0.8rem;
1998
+ font-weight: 700;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1999
  }
2000
 
2001
  @media (max-width: 1023px) {
2002
  .site-nav__menu {
2003
  display: block;
 
2004
  }
2005
 
2006
  .site-nav__menu summary {
 
2043
  border-bottom: 0;
2044
  }
2045
 
2046
+ .onboarding-hero__body,
2047
+ .burden-layout,
2048
+ .trust-grid,
2049
+ .start-section {
 
2050
  grid-template-columns: 1fr;
2051
  }
2052
 
2053
+ .evidence-grid {
2054
+ grid-template-columns: 1fr;
2055
  }
2056
  }
2057
 
2058
  @media (max-width: 760px) {
 
 
 
 
 
 
 
 
2059
  .site-nav__brand > span:not(.product-breadcrumb) {
2060
  display: none;
2061
  }
 
2065
  display: flex;
2066
  }
2067
 
2068
+ .product-breadcrumb strong,
 
 
 
2069
  .product-shell-status {
2070
  display: none;
2071
  }
 
2089
  transform: translate(-50%, -50%);
2090
  }
2091
 
2092
+ .site-nav__menu > div {
2093
+ right: 1rem;
 
2094
  }
2095
 
2096
+ .interpreter-status {
2097
+ width: min(100% - 1.5rem, var(--container));
2098
  }
2099
 
2100
+ .onboarding-hero,
2101
+ .burden-section,
2102
+ .demo-section,
2103
+ .evidence-section {
2104
  width: min(100% - 1.5rem, var(--container));
2105
  }
2106
 
2107
+ .onboarding-hero {
2108
+ padding-block: 3rem 3.5rem;
2109
  }
2110
 
2111
+ .onboarding-hero > h1 {
2112
+ font-size: clamp(1.65rem, 8.2vw, 3.15rem);
2113
+ letter-spacing: -0.055em;
2114
+ line-height: 1;
2115
  }
2116
 
2117
+ .onboarding-hero__body {
2118
+ gap: 2rem;
2119
+ margin-top: 2rem;
2120
  }
2121
 
2122
+ .calculator-fields {
2123
+ grid-template-columns: 1fr;
 
 
2124
  }
2125
 
2126
+ .site-footer {
 
2127
  grid-template-columns: 1fr;
2128
+ gap: 1.5rem;
2129
  }
2130
 
2131
+ .burden-section,
2132
+ .demo-section,
2133
+ .evidence-section {
2134
+ padding-block: 3.5rem;
2135
  }
2136
 
2137
+ .guided-sample .scribe-tabs {
2138
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2139
  }
2140
 
2141
+ .guided-sample .scribe-tab:nth-child(2) {
2142
+ border-right: 0;
2143
+ }
2144
+
2145
+ .guided-sample .scribe-tab:nth-child(3) {
2146
+ border-bottom: 0;
2147
+ }
2148
+
2149
+ .trust-section,
2150
+ .start-section {
2151
+ width: calc(100% - 1rem);
2152
+ padding: 2rem 1.25rem;
2153
+ }
2154
+
2155
+ .tool-start-guide ol {
2156
+ grid-template-columns: 1fr;
2157
  }
2158
  }
scribe/frontend/tests/demo-flow.spec.ts CHANGED
@@ -2,10 +2,8 @@ import AxeBuilder from "@axe-core/playwright";
2
  import { expect, test, type Locator, type Page } from "@playwright/test";
3
 
4
  async function tabTo(page: Page, target: Locator) {
5
- for (let attempt = 0; attempt < 140; attempt += 1) {
6
- if (await target.evaluate((element) => element === document.activeElement)) {
7
- return;
8
- }
9
  await page.keyboard.press("Tab");
10
  }
11
  throw new Error("Keyboard focus did not reach the expected control.");
@@ -15,73 +13,46 @@ async function expectNoSeriousAxeViolations(page: Page) {
15
  const results = await new AxeBuilder({ page })
16
  .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
17
  .analyze();
18
- const serious = results.violations.filter((violation) =>
19
- ["serious", "critical"].includes(violation.impact ?? ""),
20
- );
21
- expect(serious).toEqual([]);
 
22
  }
23
 
24
- test("keyboard-only visitor submits a pilot request", async ({
25
- page,
26
- }) => {
27
  await page.route("**/api/leads", async (route) => {
 
28
  await route.fulfill({ status: 204, body: "" });
29
  });
30
  await page.goto("/");
31
 
 
 
 
 
 
 
 
 
 
 
32
  const name = page.getByRole("textbox", { name: "Họ và tên" });
33
  await tabTo(page, name);
34
  await page.keyboard.type("Nguyen Minh Anh");
35
-
36
- const role = page.getByRole("textbox", { name: "Vai trò" });
37
- await tabTo(page, role);
38
- await page.keyboard.type("Giam doc phong kham");
39
-
40
- const clinic = page.getByRole("textbox", { name: "Cơ sở y tế" });
41
- await tabTo(page, clinic);
42
- await page.keyboard.type("Phong kham Minh Anh");
43
-
44
- const specialty = page.getByRole("textbox", { name: "Chuyên khoa" });
45
- await tabTo(page, specialty);
46
- await page.keyboard.type("Noi tong quat");
47
-
48
- const contact = page.getByRole("textbox", { name: "Email hoặc Zalo" });
49
- await tabTo(page, contact);
50
- await page.keyboard.type("minhanh@example.com");
51
-
52
- const submit = page.getByRole("button", {
53
- name: "Gửi yêu cầu thí điểm",
54
- });
55
- await tabTo(page, submit);
56
- await page.keyboard.press("Enter");
57
 
58
  await expect(page.getByText("Đã gửi yêu cầu thí điểm.")).toBeVisible();
 
 
59
  });
60
 
61
- test("the decision gateway has no serious axe violations", async ({
62
- page,
63
- }) => {
64
- await page.goto("/");
65
- await expectNoSeriousAxeViolations(page);
66
- });
67
-
68
- test("safety cards stay in the document flow while scrolling", async ({ page }) => {
69
- await page.setViewportSize({ width: 1440, height: 900 });
70
- await page.goto("/");
71
-
72
- const cards = page.locator("[data-safety-card]");
73
- await cards.first().scrollIntoViewIfNeeded();
74
- await page.mouse.wheel(0, 500);
75
-
76
- await expect(cards).toHaveCount(3);
77
- for (const card of await cards.all()) {
78
- await expect(card).toHaveCSS("transform", "none");
79
- }
80
- });
81
-
82
- test("both product choices remain discoverable across release viewports", async ({
83
- page,
84
- }) => {
85
  const viewports = [
86
  { width: 320, height: 800 },
87
  { width: 360, height: 800 },
@@ -94,97 +65,77 @@ test("both product choices remain discoverable across release viewports", async
94
  await page.setViewportSize(viewport);
95
  await page.goto("/");
96
 
97
- await expect(
98
- page.getByRole("heading", {
99
- level: 1,
100
- name: "Bạn muốn hỗ trợ việc gì hôm nay?",
101
- }),
102
- ).toBeVisible();
103
-
104
- for (const [key, name] of [
105
- ["scribe", "Ghi chép bệnh án AI: Bắt đầu ghi chép"],
106
- ["interpreter", "Phiên dịch khám bệnh trực tiếp: Đăng ký nh���n cập nhật"],
107
- ]) {
108
- const choice = page.locator(`.product-accordion__panel--${key}`);
109
- await expect(choice).toBeVisible();
110
- await expect(choice).toHaveAttribute("aria-label", name);
111
- expect(await choice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
112
- expect(await choice.locator(".product-accordion__cta").evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
 
 
 
 
 
 
 
 
 
 
113
  }
114
 
115
- expect(
116
- await page.evaluate(
117
- () => document.documentElement.scrollWidth - window.innerWidth,
118
- ),
119
- ).toBeLessThanOrEqual(0);
120
-
121
- await page.locator("main#top").focus();
122
- await page.keyboard.press("Tab");
123
- await expect(page.locator(".product-accordion__panel--scribe")).toBeFocused();
124
- await page.keyboard.press("Tab");
125
- await expect(page.locator(".product-accordion__panel--interpreter")).toBeFocused();
126
-
127
- if (viewport.width <= 1023) {
128
- const menu = page.locator(".site-nav__menu");
129
- const summary = menu.locator("summary");
130
- if (viewport.width === 390) {
131
- await summary.focus();
132
- await page.keyboard.press("Enter");
133
- await expect(menu).toHaveAttribute("open", "");
134
- await expect(menu.getByRole("link", { name: "Phiên dịch khám bệnh trực tiếp" })).toBeVisible();
135
- await expect(menu.getByRole("link", { name: "Ghi chép bệnh án AI" })).toBeVisible();
136
- await page.keyboard.press("Tab");
137
- await expect(
138
- menu.getByRole("link", { name: "Phiên dịch khám bệnh trực tiếp" }),
139
- ).toBeFocused();
140
- await page.keyboard.press("Enter");
141
- await expect(menu).not.toHaveAttribute("open", "");
142
- } else {
143
- await summary.click();
144
- await expect(menu.getByRole("link", { name: "Phiên dịch khám bệnh trực tiếp" })).toBeVisible();
145
- await expect(menu.getByRole("link", { name: "Ghi chép bệnh án AI" })).toBeVisible();
146
- }
147
  }
148
-
149
  }
 
150
 
151
- await expect(
152
- page.locator(".product-accordion__panel--scribe"),
153
- ).toHaveAttribute("href", "/ghi-chep-lam-sang/");
154
- await expect(
155
- page.locator(".product-accordion__panel--interpreter"),
156
- ).toHaveAttribute("href", "#pilot");
157
-
158
- await page.locator(".product-accordion__panel--interpreter").click();
159
- await expect(page).toHaveURL(/#pilot$/);
160
- await expect(page.getByRole("combobox", { name: "Chức năng quan tâm" })).toHaveValue("interpreter");
161
-
162
- await page.getByRole("combobox", { name: "Chức năng quan tâm" }).selectOption("scribe");
163
- await expect(
164
- page.getByRole("combobox", { name: "Chức năng quan tâm" }),
165
- ).toHaveValue("scribe");
166
- await page.getByRole("combobox", { name: "Chức năng quan tâm" }).selectOption("interpreter");
167
- await expect(
168
- page.getByRole("combobox", { name: "Chức năng quan tâm" }),
169
- ).toHaveValue("interpreter");
170
  });
171
 
172
- test("clinical notes use the canonical path and return to the landing page", async ({ page }) => {
173
- let healthRequested = false;
174
  await page.route("https://carepath-e2e.example/api/v1/health", async (route) => {
175
- healthRequested = true;
176
  await route.fulfill({ status: 200, json: { asr_ready: true, llm_ready: true } });
177
  });
 
178
  await page.goto("/ghi-chep-lam-sang/");
179
  await expect(page.getByText("Hệ thống sẵn sàng")).toBeVisible();
180
- expect(healthRequested).toBe(true);
 
 
181
  await expectNoSeriousAxeViolations(page);
182
- await page.locator(".nav-cta").click();
 
 
 
183
 
184
- await expect(page).toHaveURL(/\/$/);
185
- await expect(
186
- page.getByRole("heading", { name: "Bạn muốn hỗ trợ việc hôm nay?" }),
187
- ).toBeVisible();
188
  });
189
 
190
  test.describe("touch navigation", () => {
@@ -200,11 +151,9 @@ test.describe("touch navigation", () => {
200
  expect(panel).not.toBeNull();
201
  expect(panel?.x ?? -1).toBeGreaterThanOrEqual(0);
202
  expect((panel?.x ?? 0) + (panel?.width ?? 0)).toBeLessThanOrEqual(width);
203
- await expect(menu.getByRole("link", { name: "An toàn" })).toBeVisible();
204
- if (width === 390) await expectNoSeriousAxeViolations(page);
205
- await menu.getByRole("link", { name: "An toàn" }).tap();
206
  await expect(menu).not.toHaveAttribute("open", "");
207
- await expect(page).toHaveURL(/#safety$/);
208
  }
209
  });
210
  });
@@ -212,27 +161,21 @@ test.describe("touch navigation", () => {
212
  test.describe("reduced motion", () => {
213
  test.use({ reducedMotion: "reduce" });
214
 
215
- test("keeps the decision gateway static", async ({ page }) => {
216
- await page.emulateMedia({ reducedMotion: "reduce" });
217
  await page.goto("/");
218
- expect(
219
- await page.evaluate(() =>
220
- matchMedia("(prefers-reduced-motion: reduce)").matches,
221
- ),
222
- ).toBe(true);
223
- const productPanels = page.locator(".product-accordion__panel");
224
- await expect(productPanels).toHaveCount(2);
225
- for (let index = 0; index < 2; index += 1) {
226
- await expect(productPanels.nth(index)).toBeVisible();
227
- await expect(productPanels.nth(index).locator("dl")).toBeVisible();
228
- }
229
- expect(
230
- await page.locator(".product-accordion").evaluate(
231
- (element) => getComputedStyle(element).display,
232
- ),
233
- ).toBe("grid");
234
-
235
- await expect(page.locator(".product-story")).toHaveCount(0);
236
- await expect(page.locator(".marquee")).toHaveCount(0);
237
  });
238
  });
 
 
 
 
 
 
 
 
 
 
 
 
2
  import { expect, test, type Locator, type Page } from "@playwright/test";
3
 
4
  async function tabTo(page: Page, target: Locator) {
5
+ for (let attempt = 0; attempt < 120; attempt += 1) {
6
+ if (await target.evaluate((element) => element === document.activeElement)) return;
 
 
7
  await page.keyboard.press("Tab");
8
  }
9
  throw new Error("Keyboard focus did not reach the expected control.");
 
13
  const results = await new AxeBuilder({ page })
14
  .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
15
  .analyze();
16
+ expect(
17
+ results.violations.filter((violation) =>
18
+ ["serious", "critical"].includes(violation.impact ?? ""),
19
+ ),
20
+ ).toEqual([]);
21
  }
22
 
23
+ test("keyboard-only visitor can inspect the sample and submit Scribe pilot interest", async ({ page }) => {
24
+ let payload: Record<string, unknown> | undefined;
 
25
  await page.route("**/api/leads", async (route) => {
26
+ payload = route.request().postDataJSON() as Record<string, unknown>;
27
  await route.fulfill({ status: 204, body: "" });
28
  });
29
  await page.goto("/");
30
 
31
+ const firstSampleStep = page.getByRole("button", { name: "1. Cuộc trao đổi" });
32
+ await tabTo(page, firstSampleStep);
33
+ await page.keyboard.press("Tab");
34
+ await page.keyboard.press("Enter");
35
+ await expect(page.getByText("Chưa có kết quả điện tâm đồ")).toBeVisible();
36
+
37
+ const pilot = page.getByText("Dành cho cơ sở muốn thí điểm CarePath");
38
+ await tabTo(page, pilot);
39
+ await page.keyboard.press("Enter");
40
+
41
  const name = page.getByRole("textbox", { name: "Họ và tên" });
42
  await tabTo(page, name);
43
  await page.keyboard.type("Nguyen Minh Anh");
44
+ await page.getByRole("textbox", { name: "Vai trò" }).fill("Giam doc phong kham");
45
+ await page.getByRole("textbox", { name: " sở y tế" }).fill("Phong kham Minh Anh");
46
+ await page.getByRole("textbox", { name: "Chuyên khoa" }).fill("Noi tong quat");
47
+ await page.getByRole("textbox", { name: "Email hoặc Zalo" }).fill("minhanh@example.com");
48
+ await page.getByRole("button", { name: "Gửi yêu cầu thí điểm" }).click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  await expect(page.getByText("Đã gửi yêu cầu thí điểm.")).toBeVisible();
51
+ expect(payload?.interest).toBe("scribe");
52
+ await expect(page.getByRole("combobox", { name: "Chức năng quan tâm" })).toHaveCount(0);
53
  });
54
 
55
+ test("Vietnamese onboarding is compact, responsive, and keeps Interpreter unavailable", async ({ page }) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  const viewports = [
57
  { width: 320, height: 800 },
58
  { width: 360, height: 800 },
 
65
  await page.setViewportSize(viewport);
66
  await page.goto("/");
67
 
68
+ const title = page.getByRole("heading", {
69
+ level: 1,
70
+ name: "Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.",
71
+ });
72
+ await expect(title).toBeVisible();
73
+ const lineCount = await title.evaluate((element) => {
74
+ const style = getComputedStyle(element);
75
+ return Math.round(element.getBoundingClientRect().height / Number.parseFloat(style.lineHeight));
76
+ });
77
+ expect(lineCount).toBeLessThanOrEqual(viewport.width >= 1024 ? 2 : 3);
78
+
79
+ const status = page.locator("[data-interpreter-status]");
80
+ await expect(status).toContainText("Phiên dịch khám bệnh trực tiếp");
81
+ await expect(status).toContainText("Đang phát triển — hiện chưa thể truy cập trên web.");
82
+ await expect(status.locator("a, button")).toHaveCount(0);
83
+ await expect(page.getByRole("button", { name: "EN" })).toHaveCount(0);
84
+ await expect(page.locator('a[href*="phien-dich-y-khoa"], a[href*="console"]')).toHaveCount(0);
85
+
86
+ await expect(page.getByRole("link", { name: "Trải nghiệm ca khám mẫu" })).toHaveAttribute("href", "#demo");
87
+ await expect(page.getByText("2 giờ 20 phút mỗi ngày")).toBeVisible();
88
+ await expect(page.getByText("Chờ bác sĩ nhập hoặc xác nhận.")).toHaveCount(0);
89
+
90
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
91
+ if (viewport.width === 1440) {
92
+ const heroHeight = await page.locator(".onboarding-hero").evaluate((element) => element.getBoundingClientRect().height);
93
+ expect(heroHeight).toBeLessThan(800);
94
  }
95
 
96
+ if (viewport.width === 390 || viewport.width === 1440) {
97
+ await expectNoSeriousAxeViolations(page);
98
+ await page.screenshot({
99
+ fullPage: true,
100
+ path: `../../.codex/qa-evidence/cp-ux-11-${viewport.width}.png`,
101
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
 
103
  }
104
+ });
105
 
106
+ test("calculator updates transparently and the guided sample keeps decisions with the doctor", async ({ page }) => {
107
+ await page.goto("/#calculator");
108
+ await page.getByRole("spinbutton", { name: "Số người bệnh mỗi ngày" }).fill("28");
109
+ await page.getByRole("spinbutton", { name: "Phút ghi chép cho mỗi người bệnh" }).fill("5");
110
+ await expect(page.getByText("2 giờ 20 phút mỗi ngày")).toBeVisible();
111
+ await expect(page.getByText("11 giờ 40 phút cho 5 ngày làm việc")).toBeVisible();
112
+ await expect(page.getByText(/Công thức: 28 người bệnh × 5 phút/)).toBeVisible();
113
+
114
+ await page.getByRole("button", { name: "3. Bản nháp có cấu trúc" }).click();
115
+ await expect(page.getByText("Chờ bác nhập hoặc xác nhận.")).toHaveCount(2);
116
+ await page.getByRole("button", { name: "4. Bác sĩ kiểm tra" }).click();
117
+ await expect(page.getByText("Tự nhập hoặc xác nhận Nhận định và Kế hoạch.")).toBeVisible();
 
 
 
 
 
 
 
118
  });
119
 
120
+ test("clinical notes expose guidance and upload together, then return to the sample", async ({ page }) => {
 
121
  await page.route("https://carepath-e2e.example/api/v1/health", async (route) => {
 
122
  await route.fulfill({ status: 200, json: { asr_ready: true, llm_ready: true } });
123
  });
124
+ await page.setViewportSize({ width: 390, height: 844 });
125
  await page.goto("/ghi-chep-lam-sang/");
126
  await expect(page.getByText("Hệ thống sẵn sàng")).toBeVisible();
127
+ await expect(page.getByText("Ba bước để tạo bản nháp")).toBeVisible();
128
+ await expect(page.getByText("Kéo thả tệp âm thanh vào đây, hoặc bấm để chọn")).toBeVisible();
129
+ await expect(page.getByRole("button", { name: "Tiếp tục tạo bản nháp" })).toHaveCount(0);
130
  await expectNoSeriousAxeViolations(page);
131
+ await page.screenshot({
132
+ fullPage: true,
133
+ path: "../../.codex/qa-evidence/cp-ux-11-tool-390.png",
134
+ });
135
 
136
+ await page.getByRole("link", { name: "Xem ca khám mẫu trước" }).click();
137
+ await expect(page).toHaveURL(/\/#demo$/);
138
+ await expect(page.getByRole("heading", { name: "Thử một ca khám mẫu trước khi tải tệp của bạn." })).toBeVisible();
 
139
  });
140
 
141
  test.describe("touch navigation", () => {
 
151
  expect(panel).not.toBeNull();
152
  expect(panel?.x ?? -1).toBeGreaterThanOrEqual(0);
153
  expect((panel?.x ?? 0) + (panel?.width ?? 0)).toBeLessThanOrEqual(width);
154
+ await menu.getByRole("link", { name: "Ca khám mẫu" }).tap();
 
 
155
  await expect(menu).not.toHaveAttribute("open", "");
156
+ await expect(page).toHaveURL(/#demo$/);
157
  }
158
  });
159
  });
 
161
  test.describe("reduced motion", () => {
162
  test.use({ reducedMotion: "reduce" });
163
 
164
+ test("keeps content static without marquee or pinning", async ({ page }) => {
 
165
  await page.goto("/");
166
+ await expect(page.locator(".process-marquee, .scribe-story")).toHaveCount(0);
167
+ await expect(page.locator(".onboarding-hero")).toHaveCSS("transform", "none");
168
+ expect(await page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === "running").length)).toBe(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  });
170
  });
171
+
172
+ for (const colorScheme of ["light", "dark"] as const) {
173
+ test(`keeps the main action readable in ${colorScheme} mode`, async ({ browser }) => {
174
+ const context = await browser.newContext({ colorScheme });
175
+ const page = await context.newPage();
176
+ await page.goto("http://127.0.0.1:4173/");
177
+ await expect(page.getByRole("link", { name: "Trải nghiệm ca khám mẫu" })).toBeVisible();
178
+ await expectNoSeriousAxeViolations(page);
179
+ await context.close();
180
+ });
181
+ }
scribe/training/scripts/validate_soap_ratings.py CHANGED
@@ -1,4 +1,4 @@
1
- """Validate a de-identified clinician SOAP-rating export without reading note content."""
2
 
3
  from __future__ import annotations
4
 
@@ -21,7 +21,7 @@ SCORE_LIMITS = {"completeness": (1, 5), "hallucination": (0, 3), "terminology":
21
  STATUSES = {"accepted", "needs_correction", "unsafe"}
22
 
23
 
24
- def summarize_ratings(rows: list[dict[str, str]], min_notes: int = 50) -> dict[str, object]:
25
  if not rows:
26
  raise ValueError("rating export has no rows")
27
  notes: set[str] = set()
@@ -59,17 +59,15 @@ def summarize_ratings(rows: list[dict[str, str]], min_notes: int = 50) -> dict[s
59
  "mean_terminology": round(totals["terminology"] / count, 2),
60
  "serious_hallucinations": serious_hallucinations,
61
  "unsafe_notes": unsafe,
62
- "ready_for_decision": len(notes) >= min_notes,
63
  }
64
 
65
 
66
  def main() -> None:
67
  parser = argparse.ArgumentParser(description=__doc__)
68
  parser.add_argument("--input", required=True, type=Path)
69
- parser.add_argument("--min-notes", type=int, default=50)
70
  args = parser.parse_args()
71
  with args.input.open(encoding="utf-8", newline="") as stream:
72
- summary = summarize_ratings(list(csv.DictReader(stream)), args.min_notes)
73
  print(json.dumps(summary, ensure_ascii=False, indent=2))
74
 
75
 
 
1
+ """Summarize an in-house SOAP review export without reading note content."""
2
 
3
  from __future__ import annotations
4
 
 
21
  STATUSES = {"accepted", "needs_correction", "unsafe"}
22
 
23
 
24
+ def summarize_ratings(rows: list[dict[str, str]]) -> dict[str, object]:
25
  if not rows:
26
  raise ValueError("rating export has no rows")
27
  notes: set[str] = set()
 
59
  "mean_terminology": round(totals["terminology"] / count, 2),
60
  "serious_hallucinations": serious_hallucinations,
61
  "unsafe_notes": unsafe,
 
62
  }
63
 
64
 
65
  def main() -> None:
66
  parser = argparse.ArgumentParser(description=__doc__)
67
  parser.add_argument("--input", required=True, type=Path)
 
68
  args = parser.parse_args()
69
  with args.input.open(encoding="utf-8", newline="") as stream:
70
+ summary = summarize_ratings(list(csv.DictReader(stream)))
71
  print(json.dumps(summary, ensure_ascii=False, indent=2))
72
 
73
 
scribe/training/soap_eval/README.md CHANGED
@@ -1,14 +1,17 @@
1
- # SOAP Note Clinical Rating Protocol
2
 
3
  This Scribe-only quality track contains only the anonymous rating schema. Do not put audio,
4
  transcripts, patient identifiers, or SOAP note text in this repository.
5
 
6
- ## Owner prerequisites
7
 
8
- Before creating the real rating export, the owner must have a pilot-clinic
9
- agreement, documented consent or other lawful basis, de-identification process,
10
- and an approved secure review environment. The clinical source material stays
11
- there; this repository is not that environment.
 
 
 
12
 
13
  ## Rubric
14
 
@@ -22,9 +25,9 @@ Rate each generated note using an anonymous `note_id` only.
22
  | `status` | `accepted`, `needs_correction`, `unsafe` | Clinician's final disposition. |
23
 
24
  `clinician_id` is an approved pseudonymous reviewer identifier. `reviewed_at`
25
- uses ISO 8601 date/time. At least 50 distinct notes must be rated before a
26
- fine-tuning decision. The validator reports readiness to **decide**, never
27
- approval to fine-tune.
28
 
29
  ## Run
30
 
@@ -34,6 +37,7 @@ Export ratings without clinical source text to an approved local path, then run:
34
  python scribe/training/scripts/validate_soap_ratings.py --input C:\approved\soap-ratings.csv
35
  ```
36
 
37
- Review the resulting summary with the clinical owner. Any hallucination score
38
- of 2 or 3, or any `unsafe` disposition, requires documented investigation
39
- before deciding whether training, retrieval, or prompting should change.
 
 
1
+ # SOAP Note In-House Review Protocol
2
 
3
  This Scribe-only quality track contains only the anonymous rating schema. Do not put audio,
4
  transcripts, patient identifiers, or SOAP note text in this repository.
5
 
6
+ ## Current scope
7
 
8
+ This tool supports informational in-house testing only. Use synthetic or
9
+ already-approved de-identified material outside the repository. Do not put
10
+ source audio, transcripts, patient identifiers, or SOAP note text here.
11
+
12
+ The summary does not create a release target or authorize fine-tuning,
13
+ provider, or safety-policy changes. Those changes require a separate owner
14
+ decision.
15
 
16
  ## Rubric
17
 
 
25
  | `status` | `accepted`, `needs_correction`, `unsafe` | Clinician's final disposition. |
26
 
27
  `clinician_id` is an approved pseudonymous reviewer identifier. `reviewed_at`
28
+ uses ISO 8601 date/time. There is no minimum sample-size target. The validator
29
+ reports quality signals only; it never reports readiness or approval to change
30
+ the model.
31
 
32
  ## Run
33
 
 
37
  python scribe/training/scripts/validate_soap_ratings.py --input C:\approved\soap-ratings.csv
38
  ```
39
 
40
+ Review the resulting summary with the designated in-house reviewer. Any
41
+ hallucination score of 2 or 3, or any `unsafe` disposition, requires documented
42
+ investigation and retention of the current model behavior unless a separate
43
+ owner decision authorizes a change.
scribe/training/tests/test_soap_ratings.py CHANGED
@@ -25,18 +25,18 @@ def _row(note_id: str, **overrides: str) -> dict[str, str]:
25
 
26
 
27
  class SoapRatingsTests(unittest.TestCase):
28
- def test_summary_requires_fifty_distinct_notes(self) -> None:
29
- rows = [_row(f"note-{index}") for index in range(50)]
30
  summary = summarize_ratings(rows)
31
- self.assertTrue(summary["ready_for_decision"])
32
- self.assertEqual(summary["unique_notes"], 50)
 
33
 
34
  def test_summary_reports_serious_hallucinations(self) -> None:
35
  rows = [_row("note-1", hallucination="2", status="unsafe")]
36
  summary = summarize_ratings(rows)
37
  self.assertEqual(summary["serious_hallucinations"], 1)
38
  self.assertEqual(summary["unsafe_notes"], 1)
39
- self.assertFalse(summary["ready_for_decision"])
40
 
41
  def test_rejects_out_of_range_score(self) -> None:
42
  with self.assertRaisesRegex(ValueError, "hallucination"):
 
25
 
26
 
27
  class SoapRatingsTests(unittest.TestCase):
28
+ def test_summary_reports_unique_notes_without_a_readiness_gate(self) -> None:
29
+ rows = [_row("note-1"), _row("note-1"), _row("note-2")]
30
  summary = summarize_ratings(rows)
31
+ self.assertEqual(summary["rating_rows"], 3)
32
+ self.assertEqual(summary["unique_notes"], 2)
33
+ self.assertNotIn("ready_for_decision", summary)
34
 
35
  def test_summary_reports_serious_hallucinations(self) -> None:
36
  rows = [_row("note-1", hallucination="2", status="unsafe")]
37
  summary = summarize_ratings(rows)
38
  self.assertEqual(summary["serious_hallucinations"], 1)
39
  self.assertEqual(summary["unsafe_notes"], 1)
 
40
 
41
  def test_rejects_out_of_range_score(self) -> None:
42
  with self.assertRaisesRegex(ValueError, "hallucination"):