diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fb89ffe55..b50e0fc634 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,9 @@ jobs: - name: Runtime alignment run: npm run check:runtime + - name: Installed dependency parity + run: npm run check:installed-lock-parity + - name: GitHub Actions pin check run: npm run check:github-actions diff --git a/AGENTS.md b/AGENTS.md index 905b49ec07..222a970b9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,7 +166,7 @@ When a branch or PR review completes, append the reviewed branch/ref, HEAD SHA, - For non-trivial source/config/test changes, prefer `npm run verify:cheap` as the first broad gate and `npm run verify:pr-local` before PR handoff when the change is ready. The PR-local gate runs the full unit suite once, then conditionally adds the production build/client-bundle scan and RAG fixture/manifest validation. Browser, dependency-audit, Docker/Supabase replay, and provider-backed checks remain separate gates. Use `npm run verify:pr-local -- --dry-run --files ` to inspect selection without running commands. The broader `--extended` plan is dry-run only unless explicit approval is reflected by `ALLOW_EXTENDED_PR_LOCAL=true`. - Let the repository run coordinator control cross-worktree verification. It permits at most two focused Vitest/read-only typecheck leases from different worktrees; full Vitest, coverage, lint, build, Playwright, and live-provider tests remain exclusive. Do not install while a repository test, build, lint, typecheck, or server command is active. Avoid aggressive short-interval polling, and do not repeat an unchanged full gate after it passes. -- For UI, frontend, browser, routing, styling, reduced-motion, or forced-colors changes, run `npm run ensure` before browser work and use `npm run verify:ui` as the Chromium UI gate. +- For UI, frontend, browser, routing, styling, reduced-motion, or forced-colors changes, run `npm run ensure` before browser work and use `npm run verify:ui` as the Chromium UI gate. For phone-chrome changes, run `npm run verify:phone-chrome` first: it checks installed-lock parity, selects the affected browser/PWA owners and exact journeys, and adds `verify:ui` last only when shared chrome foundations make the broad gate necessary. Inspect uncertain scope with `-- --dry-run`. - For release or handoff confidence, use `npm run verify:release`; this includes the full Playwright project set. - For clinical ingestion, answer generation, source governance, privacy, production-readiness, or environment changes, run the smallest relevant domain check plus `npm run check:production-readiness`. - For pull requests that touch ingestion, answer generation, search/ranking, source rendering, document access, privacy, production env, or clinical output, complete the clinical governance preflight in `.github/pull_request_template.md`. @@ -218,7 +218,7 @@ The shared search chrome must adapt by page ownership, not by ad-hoc padding or - **Hidden means zero reserve.** When phone search/header/footer chrome scroll-hides, the content-facing reserve is `0rem`; do not restore `0.75rem`, `env(safe-area-inset-bottom)`, or `var(--safe-area-bottom)` as hidden padding. Visible composer chrome may still consume safe-area inset. - **Header/footer symmetry.** Top header and bottom composer hide/reveal from the same scroll signal where they share a scroll container. If one is hidden, page content behind that edge must be fully visible rather than covered by an opaque white/surface band. - **Page adaptation.** Standalone mode homes keep the composer in-flow in the hero on phones; submitted/search-result views use the compact bottom dock; answer mode may use overlaid glass header behaviour with matching top reserve; document detail/source routes let `DocumentViewer` own its composer. -- **Guards.** Update the reserve helper, CSS tokens, Playwright phone-scroll coverage, and static contract tests together. Do not silence the existing reserve/overlay tests; add a narrower guard for any new page-specific exception. +- **Guards.** Update the reserve helper, CSS tokens, Playwright phone-scroll coverage, and static contract tests together. Do not silence the existing reserve/overlay tests; add a narrower guard for any new page-specific exception. Run `npm run verify:phone-chrome`; its smart selector must keep focused owner/journey proof before any recommended full `verify:ui` escalation. diff --git a/docs/README.md b/docs/README.md index cb90326fff..39d24527c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,6 +64,7 @@ npm run docs:check-links - [process-hardening.md](process-hardening.md) — verification gates, CI expectations, known debts - [testing.md](testing.md) — test execution, focused/live commands, Playwright ownership, flake policy +- [phone-chrome-physical-acceptance.md](phone-chrome-physical-acceptance.md) — labelled Safari and cold-launch PWA acceptance matrix - [productivity-workflows.md](productivity-workflows.md) — repo workflow planners (flightplan, triage, rag-lab, …) - [codex-review-protocol.md](codex-review-protocol.md) — shared review protocol for all review skills - [codex-prompt-playbook.md](codex-prompt-playbook.md) — copy/paste prompts for common repo work diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 506952ab7e..bae53f24a6 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1124,4 +1124,9 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-27 | branch-cleanup exact merged-head batch | `multi-head` | branch-cleanup | REMOVED. Deleted 30 additional clean worktrees and 34 additional local branches whose exact tips matched GitHub merged PR head records. Preserved the newly merged `codex/settings-ux` worktree under a recent-work grace rule, along with all checked-out, dirty, open-PR, closed-unmerged, secret-safeguarded, process-locked, or unmatched refs. | GitHub merged/open PR head inventory; exact OID match immediately before removal; no force worktree removal; no application tests or provider-backed application workflows run. | | 2026-07-27 | branch-cleanup aged and closed-ref batch | `multi-head` | branch-cleanup | REMOVED. Deleted 36 clean branch-backed worktrees older than 72 hours while retaining their refs, then deleted 16 old temporary or exact closed-PR local refs and 11 exact closed-PR remote refs after verified recovery bundles. Restored and retained one permission-locked Antigravity worktree; retained all dirty, open-PR, recent, archive/preserve, secret-bearing, high-risk, divergent, detached, or potentially useful UI refs. | Fresh GitHub open/all/closed PR inventories; 72-hour creation/closure cutoff; exact tip checks; four verified incremental bundles for 21 refs in this pass; no force worktree removal, application tests, or provider-backed application workflows run. | | 2026-07-27 | `codex/publish-document-nav-20260727` (PR #1278) | `71d442e7921c36fe036128207c9925484a908fd0` | Protected-main review of preserved cleanup records, reusable review prompts, and document navigation mockups | APPROVE pending final exact-head hosted required checks. The unique preserved work was transplanted onto current `origin/main`; stale phone-chrome and unsafe 15-minute lock-expiry patches were excluded. The first hosted static run found arbitrary mockup font sizes, which were replaced with the established named type-scale tokens. Review found no remaining P0-P3 issue and no retrieval, clinical-output, provider, or production-route behavior change. | Flight plan, Prettier, docs index/scripts/links, sitemap, branch-ledger, type-scale, icon-scale, brand, design-system, and `git diff --check` PASS; hosted build, static, unit coverage, advisory mockup UI, safety, policy, Semgrep, and secret checks PASS on reviewed head; Production UI pending at ledger append; local heavy gates deferred behind legitimate shared exclusive owners; no non-GitHub provider-backed checks. | +| 2026-07-27 | `codex/phone-chrome-testing-infra-20260727` | `f50bb000cd9addf98086cfccc07e25aa327be022` | Protected-main release-readiness review of phone-chrome testing infrastructure | APPROVE pending exact-head hosted required checks. Added installed-lock parity, risk-scoped phone verification, one-owner and atomic geometry helpers, stable chrome diagnostics, physical iPhone acceptance guidance, and a fail-closed final merge audit. The full Chromium run exposed a stale endpoint assertion while chrome changed the document range; the test now re-converges the endpoint without weakening its one-pixel contract. Current `origin/main` merged cleanly; its new Playwright matcher is mockup-only and does not alter the tested production project. No P0-P3 finding remains. Residual acceptance risk is physical Safari and cold-launch PWA paint, which was not available locally. | `verify:phone-chrome` PASS: contracts 91/91, focused phone journeys 12/12, full Chromium 323/323; exact repaired differential journey 1/1; Prettier, ESLint, typecheck, full unit 396 files / 3,554 passed / 2 skipped, production build/client-secret scan, offline RAG fixtures 36 cases, and `git diff --check` PASS; no live provider-backed app checks. | +| 2026-07-27 | PR #1279 / `codex/phone-chrome-testing-infra-20260727` | `9aa0416313addaa9fc3a850c699b0e51f3a14c6c` | Hosted Production UI split-owner and hydration follow-up | APPROVE pending fresh exact-head hosted checks. The retained CI traces proved the calculator footer and frame header could independently accept or reject the same scroll event under slower RAF scheduling. Page-owned calculator chrome now consumes the frame's authoritative hide decision, with local reporters only as a shell-less fallback. A separate desktop smoke timeout filled a controlled input before React attached `onChange`; the shared fill helper now establishes that handler boundary before all 16 answer journeys. No assertions were relaxed and no P0-P3 finding remains. Residual acceptance risk remains physical Safari and cold-launch PWA paint, which was not available locally. | Final `verify:phone-chrome` PASS: installed/lock parity, runtime, contracts 92/92, focused phone journeys 12/12, full Chromium 323/323; exact two calculator regressions plus desktop hydration journey PASS 3/3; Prettier, typecheck, production builds, and `git diff --check` PASS; no non-GitHub provider-backed checks. | +| 2026-07-27 | PR #1279 / `codex/phone-chrome-testing-infra-20260727` | `d6d76f9b61479997f747ddd45767d81b851e207c` | Exact-main sync review after hydrated-interaction test hardening | APPROVE pending fresh exact-head hosted checks. Current `origin/main` merged with one test-only overlap in the differential compare endpoint journey. The resolution preserves main's handler-readiness guard and 15-second live-endpoint convergence while keeping the feature branch's atomic document-owner assertion. Main's remaining hydrated-interaction changes merged automatically; no production component, phone geometry, or retrieval behavior conflicted. No P0-P3 finding remains. | Pre-sync `verify:phone-chrome` PASS: 92/92 contracts, 12/12 focused phone journeys, 323/323 full Chromium; post-sync exact resolved production differential journey PASS 1/1; production build/TypeScript and `git diff --check` PASS; hosted required checks pending. | | 2026-07-27 | `codex/settings-followup` | `806fcc4c3167d9e2f9fbd832c39e53d3491f270a` | Protected-main release-readiness review of settings follow-up browser reliability | APPROVE. The test-only diff waits for one settled React owner before strict answer/search interactions, makes universal-search mocks echo the requested query, and retries scroll-to-live-endpoint geometry after late dock layout. Review found no P0-P3 issue and no product, retrieval, ranking, clinical-output, or provider behavior change. Highest residual risk is physical iOS/WebKit behavior outside local Chromium coverage. | Focused integrated production Chromium PASS (5/5); exact integrated-head `verify:pr-local` PASS (runtime, formatting, lint, typecheck, 393 files, 3,538 passed / 2 skipped, 36 offline RAG fixtures); `verify:ui` PASS (323/323); `git diff --check` PASS; no non-GitHub provider-backed checks. | +| 2026-07-27 | PR #1279 / `codex/phone-chrome-testing-infra-20260727` | `e1add338ae6b138ded13c44f5cc8f7eef5302669` | Automated-review follow-up for required-check selection in the final merge audit | APPROVE pending fresh exact-head hosted checks. The P1 was valid: the audit selected the required aggregate but incorrectly treated every advisory status as merge-blocking. Unsettled-state validation now applies only to the required aggregate, while missing or unsuccessful `pr-required` still fails closed. A regression snapshot proves a failed Advisory UI job cannot block a successful required aggregate. No other P0-P3 finding remains. | Focused `tests/final-merge-audit.test.ts` PASS (7/7); Prettier and `git diff --check` PASS; earlier exact-tree `verify:phone-chrome` remains the browser baseline because this follow-up changes only the audit validator and its unit test; hosted required checks must rerun on this head. | +| 2026-07-27 | PR #1279 / `codex/phone-chrome-testing-infra-20260727` | `a2331fc8d1883d687d0bbb6e1b023503ab5deb1d` | Automated-review follow-up for changed Playwright journey selection | APPROVE pending fresh exact-head hosted checks. The P2 was valid: fixed title filters could omit a modified journey while the planner still reported focused coverage. Changed phone-chrome Playwright specs now run completely without `--grep`; the title-filtered matrix remains only for relevant unchanged specs, preserving focused-first feedback without hiding edited tests. Regression cases cover both `ui-phone-scroll` and `ui-tools`. No other P0-P3 finding remains. | Focused `tests/verify-phone-chrome.test.ts` PASS (7/7); smart-plan dry run selects complete changed specs before the risk-selected full UI suite; Prettier and `git diff --check` PASS; fresh hosted required checks must rerun on this head. | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 2947232dbd..c43c693f86 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -316,20 +316,21 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: ## Key config files -| File | Role | -| ----------------------------------- | ----------------------------------------------------- | -| `package.json` | Scripts, deps, Node 24 / npm 11 | -| `.env.example` | Full env template | -| `next.config.ts` | CSP, security headers, build config | -| `tsconfig.json` | Strict TS; excludes `supabase/functions/**` | -| `eslint.config.mjs` | Lint scope | -| `AGENTS.md` | Agent rules, verification gates, shortcuts | -| `.github/workflows/ci.yml` | CI pipeline | -| `scripts/sync-open-pr-branches.mjs` | Operator-only dry-run/apply helper for PR branch sync | -| `docs/process-hardening.md` | Verification pyramid | -| `docs/clinical-governance.md` | Clinical safety governance | -| `docs/reindex-runbook.md` | Reindex operations | -| `docs/retrieval-quality-runbook.md` | Retrieval tuning | +| File | Role | +| ------------------------------------------ | --------------------------------------------------------- | +| `package.json` | Scripts, deps, Node 24 / npm 11 | +| `.env.example` | Full env template | +| `next.config.ts` | CSP, security headers, build config | +| `tsconfig.json` | Strict TS; excludes `supabase/functions/**` | +| `eslint.config.mjs` | Lint scope | +| `AGENTS.md` | Agent rules, verification gates, shortcuts | +| `.github/workflows/ci.yml` | CI pipeline | +| `scripts/sync-open-pr-branches.mjs` | Operator-only dry-run/apply helper for PR branch sync | +| `docs/process-hardening.md` | Verification pyramid | +| `docs/phone-chrome-physical-acceptance.md` | Physical Safari / cold-launch PWA phone-chrome acceptance | +| `docs/clinical-governance.md` | Clinical safety governance | +| `docs/reindex-runbook.md` | Reindex operations | +| `docs/retrieval-quality-runbook.md` | Retrieval tuning | --- diff --git a/docs/codex-review-protocol.md b/docs/codex-review-protocol.md index 5117780b7b..b7a9683b1d 100644 --- a/docs/codex-review-protocol.md +++ b/docs/codex-review-protocol.md @@ -38,3 +38,13 @@ Use this protocol for every Codex review, audit, bug hunt, PR review, release-re - P1: Broken core workflow, unsafe automation, privacy/auth failure, or repeatable defect that blocks merge/handoff. - P2: Real defect, missing guardrail, fragile process, or test gap that should be fixed before relying on the work. - P3: Low-risk cleanup, clarity, documentation, or future-proofing issue. + +## Final merge audit + +Before a protected-main merge, run the local audit from a clean PR checkout and pin the reviewed head: + +```powershell +npm run audit:final-merge -- --dry-run --base-ref origin/main --head-ref HEAD --expected-head +``` + +The output records the local base/head and expected merge-tree. GitHub checks, labels, review threads, fresh remote refs, and deployment health are provider reads and require explicit authorization. Once authorized, add `--providers --pr --repo BigSimmo/Database` and set `ALLOW_PROVIDER_READS=true`; the audit fails closed unless the repository's `pr-required` aggregate is present and settled successfully. After the squash merge, rerun with `--post-merge --expected-tree --health-url /api/health`; the audit compares the remote main tree and requires an HTTP success with JSON `status: "ok"`. The script is read-only: it never merges, pushes, reruns CI, resolves threads, or deploys. diff --git a/docs/phone-chrome-physical-acceptance.md b/docs/phone-chrome-physical-acceptance.md new file mode 100644 index 0000000000..ff9c349096 --- /dev/null +++ b/docs/phone-chrome-physical-acceptance.md @@ -0,0 +1,45 @@ +# Physical iPhone phone-chrome acceptance + +Run this labelled checklist after `npm run verify:phone-chrome` passes and before merging a change that affects shared phone chrome, viewport ownership, safe areas, composer reserves, or hide/reveal motion. Chromium geometry is necessary but cannot prove pixels owned by iOS WebKit. + +## Build and evidence label + +Record one label for the complete pass: + +- PR / branch: +- commit SHA: +- staging URL and deployment ID: +- iPhone model: +- iOS version: +- tester and timestamp: + +Use the same commit for Safari and the cold-launched Home Screen app. Do not accept a warm PWA that may still be running an older service worker or cached bundle. + +## Matrix + +Complete every row in both light and dark theme. Portrait is required for every row; repeat the visible/hidden rows in landscape. Mark `pass`, `fail`, or `not applicable` and attach the matching screenshot/video label. + +| Surface | Container | Keyboard | Scroll direction / state | Required result | Result / evidence | +| ------------------------------------------------------- | --------------- | ------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------- | +| Submitted Services or Forms results | Safari tab | closed | top, chrome visible | One header owner and one footer owner; app content paints continuously around Safari's native controls | | +| Submitted Services or Forms results | Safari tab | closed | deliberate down, chrome hidden | Header and footer fully release together; no app-owned top or bottom band; no content jump or oscillation | | +| Submitted Services or Forms results | Safari tab | closed | deliberate up, chrome revealed | Both edges return smoothly and remain tappable | | +| Submitted Services or Forms results | Safari tab | open then dismissed | down and up | Focus pins visible chrome while the keyboard is open; dismissal restores hide/reveal without stale reserve | | +| Answer result | Safari tab | closed | visible, hidden, revealed | Overlay header and answer dock remain symmetric; content position changes monotonically | | +| Document detail | Safari tab | closed | visible, hidden, revealed | Universal header and document-owned composer follow the document scroll owner together | | +| Page-owned footer (calculator or differential workflow) | Safari tab | closed | visible and hidden where supported | Footer reaches its intended physical edge and releases only its own reserve | | +| Submitted Services or Forms results | cold-launch PWA | closed | top, chrome visible | Inner main scroller owns movement; footer is frame-owned and reaches the PWA edge | | +| Submitted Services or Forms results | cold-launch PWA | closed | down, hidden; up, revealed | Same symmetric, jump-free behaviour as Safari; no retained safe-area strip | | +| Document detail | cold-launch PWA | closed | visible, hidden, revealed | Page-owned footer stays outside the inner scroller and anchored to the 100vh frame | | +| Any bottom composer | cold-launch PWA | open then dismissed | rotate portrait to landscape and back | Keyboard and orientation changes do not leave a stale reserve, duplicate owner, or unreachable control | | + +## Pass criteria + +- The app owns exactly one top-chrome surface and at most one footer/composer surface. +- `data-phone-scroll-owner`, `data-phone-footer-owner`, `data-phone-composer-reserve`, and `data-phone-chrome-transition` agree with the visible layout when inspected through Safari Web Inspector. +- Hidden chrome has zero app-owned reserve at that edge. Native Safari status/address controls may remain visible; pixels outside the reported web viewport are system-owned. +- No contrasting app-owned band surrounds Safari's native controls. Match the root canvas colour; do not use negative safe-area overscan, fixed-root tricks, or synthetic padding to imitate ownership of system pixels. +- Hide and reveal are monotonic, smooth, and symmetric. There is no geometry jump, bottom clamp loop, flash of a duplicate composer, or scroll position reset. +- Light/dark, portrait/landscape, keyboard open/closed, down/up scroll, and visible/hidden states all pass for both Safari and a cold-launch PWA. + +If any row fails, retain the commit label and evidence, add the exact route/state to the focused Playwright coverage where emulation is meaningful, and rerun the entire affected container column after the fix. diff --git a/docs/scripts-index.md b/docs/scripts-index.md index bd8d316c00..91eaa135a4 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -18,6 +18,8 @@ migration has shipped (see `docs/maturity-backlog-workorders.md` L1). | `dev-free-port.mjs`, `ensure-local-server.mjs` | Project-stable localhost port selection + background server ensure | | `check-node-engine.cjs`, `install-git-hooks.mjs`, `guard-push.mjs`, `guard-next-build.mjs` | Install/preflight guards | | `ci-change-scope.mjs`, `ci-triage.mjs`, `pr-policy.mjs` | CI change classification + PR policy (self-tested via `check:ci-scope`/`check:ci-triage`/`check:pr-policy`) | +| `check-installed-lock-parity.mjs`, `phone-chrome-plan.mjs`, `verify-phone-chrome.mjs` | Lock-trust preflight plus change-scoped phone contracts, ownership journeys, and smart full-UI escalation | +| `final-merge-audit.mjs` | Fail-closed local merge-tree audit; explicit provider mode adds PR/check/thread/tree/deployment proof | | `child-process-result.mjs`, `cli-utils.ts`, `productivity-core.mjs` | Shared helpers | ## Verification gates [live] @@ -31,6 +33,8 @@ migration has shipped (see `docs/maturity-backlog-workorders.md` L1). `verify-release-offline.mjs`. `check-gate-manifest.mjs` cross-checks that every gate in the `verify:cheap:internal` chain also runs in CI's `static-pr` job, so the two lists can't drift. +For executable phone-chrome changes, use `verify:phone-chrome` before the broad UI gate. It checks installed-lock parity, then selects focused contracts and Playwright owners from the changed paths; shared foundations add `verify:ui` last. Documentation-only scopes run only documentation guards. `audit:final-merge` is local-only unless both `--providers` and `ALLOW_PROVIDER_READS=true` are supplied. + ## Ingestion, indexing & reindex [live] `import-documents.ts`, `reindex.ts`, `reindex-health.ts`, `check-indexing.ts`, diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 6310fb782b..b78b7003a4 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -36,6 +36,7 @@ This repo uses one shared search experience across the global shell, dashboard r 19. `ClinicalDashboard` must stay out of the shared shell's static import graph (dynamic import) so namespaced mode routes do not parse the dashboard module. 20. Browser-mode phones keep `.phone-viewport-shell` in normal flow and use the document as the vertical scroll owner. This is required for Safari to minimize its own browser chrome; do not restore a fixed/inset root or a phone `overflow-y: auto` canvas. 21. Installed standalone phones use the same normal-flow root with the final `display-mode: standalone` `100vh` bound and an internal `.phone-scroll-surface`. Keep that override after the browser contract; do not substitute `svh`, `dvh`, `visualViewport.height`, or a fixed root on this WebKit workaround path. Every phone footer uses `.phone-footer-layer`: fixed to the viewport in browser tabs and absolute to the positioned 100vh frame in standalone, so the composer and its backdrop share the repaired PWA edge. Page-owned footer layers must render through `PhoneFooterLayerPortal`; `PhoneFooterLayerFrame` provides a frame-scoped, paint-free host after the scroll surface. An absolute footer left inside `.phone-scroll-surface` still scrolls and clips with that surface. +22. Safari's status bar, collapsing address bar, and pixels outside `window.innerHeight` are native browser/system controls. Do not use negative safe-area overscan, a fixed app root, synthetic document padding, or an opaque viewport slab to make CSS appear to own those pixels. Acceptance is no contrasting **app-owned** band around the native controls, with a matching opaque root canvas. Use the labelled physical-device matrix in [phone-chrome-physical-acceptance.md](phone-chrome-physical-acceptance.md). ## Scroll hide/reveal @@ -68,6 +69,8 @@ Rules that keep this working: Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet pinned-search behaviour and desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry), `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). +Run `npm run verify:phone-chrome` for phone-chrome work. For executable changes its classifier checks installed/lock parity first, runs focused static contracts and only the browser/PWA owners and route journeys implicated by the changed files, then escalates to `npm run verify:ui` automatically for shared chrome foundations. Documentation-only scopes run only documentation guards. Use `-- --dry-run` to inspect the plan, `-- --files ` for an explicit scope, and `-- --full=always|never` only for a deliberate override. + ## Change checklist Before changing search bar behaviour: @@ -77,5 +80,6 @@ Before changing search bar behaviour: - Update the reserve helper and CSS token together when changing clearances. - Add or update a focused static contract test for new constants or exceptions. - For visual/scroll changes, run the relevant phone-scroll/overlap Playwright coverage through `npm run ensure` and `npm run verify:ui` when the environment supports the repo runtime. +- Complete [the physical iPhone checklist](phone-chrome-physical-acceptance.md) for shared safe-area/ownership changes; local Chromium cannot certify Safari or cold-launch PWA physical paint. - For hide-on-scroll changes, re-read "Scroll hide/reveal" and prove the reveal at tablet and desktop, not just the hide. - If a new route has a page-owned composer, document it here and add it to the route/search coverage rather than relying on comments in a component. diff --git a/docs/testing.md b/docs/testing.md index 1aea56c0f4..8bdc744440 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,6 +25,7 @@ Ordinary Vitest and Playwright runs remove OpenAI, Supabase, database, and E2E c | `npm run test:e2e:advisory` | Quarantined and mockup journeys in one advisory invocation. | | `npm run verify:cheap` | Broad offline local gate: runtime/config checks, lint, typecheck, and the full unit suite. | | `npm run verify:pr-local` | PR-like local gate. Formatting is checked on the changed set, the full unit suite runs once, and RAG scope adds fixture/manifest validation. | +| `npm run verify:phone-chrome` | Smart phone-chrome gate: lock parity, affected contracts, browser/PWA owners and exact journeys, then full UI only for shared foundations. | | `npm run verify:ui` | Complete required production Chromium gate. | Set `FAST_CHECK_SEED` to reproduce a property-test run. Local and ordinary CI runs default to `424242`; scheduled CI may derive a bounded seed from the run ID. @@ -51,6 +52,8 @@ The repository runner exclusively builds and serves each Playwright production a Blocking tests run with zero retries. CI publishes list, JUnit, and JSON reports. Failed-test classification parses JUnit test cases and uses exact spec/title matches; a job name is never enough to classify a failure as a known flake. +Phone-chrome work uses `npm run verify:phone-chrome`. Inspect its classification with `-- --dry-run` or provide an explicit changed set with `-- --files pathA,pathB`. The default `--full=auto` escalates shared shell/header/footer, scroll-coordinator, reserve, or global-style changes to `verify:ui` only after focused ownership and journey checks pass. Page-local owners and test-helper changes remain focused; use `--full=always` for deliberate extra confidence or `--full=never` only when the dry run records why the recommended broad gate is unavailable. Physical Safari and cold-launch PWA paint still follow [phone-chrome-physical-acceptance.md](phone-chrome-physical-acceptance.md). + ## Flake policy `tests/flake-ledger.json` may be empty. Each entry must match the exact spec and title, and the test title must include `@quarantine` but not `@critical`. Entries require an owner, reproduction command, local tracking reference, first/last-seen dates, and an expiry no more than 30 days away. Reproduce a candidate three times on the same SHA before adding or retaining it: fix fail/pass races, treat repeatable failures as regressions, and remove entries that no longer reproduce. diff --git a/package.json b/package.json index 6367c667da..dc602114bb 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,14 @@ "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts", "clean:worktree": "node scripts/clean-worktree.mjs", - "verify:preflight": "npm run typecheck && npm run verify:cheap && npm run clean:worktree", + "verify:preflight": "npm run check:installed-lock-parity && npm run typecheck && npm run verify:cheap && npm run clean:worktree", "verify:cheap": "npm run verify:cheap:internal", - "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run check:branch-review-ledger && npm run sitemap:check && npm run docs:check-index && npm run docs:check-scripts && npm run docs:check-links && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:assets && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:migration-role && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", + "verify:cheap:internal": "npm run check:runtime && npm run check:installed-lock-parity && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run check:branch-review-ledger && npm run sitemap:check && npm run docs:check-index && npm run docs:check-scripts && npm run docs:check-links && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:assets && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:migration-role && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", "verify:pr-local": "node scripts/verify-pr-local.mjs", - "verify:ui": "npm run check:runtime && npm run test:e2e:pr", - "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", + "verify:phone-chrome": "node scripts/verify-phone-chrome.mjs", + "audit:final-merge": "node scripts/final-merge-audit.mjs", + "verify:ui": "npm run check:runtime && npm run check:installed-lock-parity && npm run test:e2e:pr", + "verify:release": "npm run check:runtime && npm run check:installed-lock-parity && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", "verify:release:offline": "node scripts/verify-release-offline.mjs", "ci:env-check": "node scripts/check-ci-env.mjs", "check:github-actions": "node scripts/check-github-action-pins.mjs", @@ -76,6 +78,7 @@ "brand:check": "node scripts/run-tsx.mjs scripts/generate-brand-assets.ts --check", "check:therapy-data-index": "node scripts/build-therapies-index.mjs --check", "check:runtime": "node scripts/run-tsx.mjs scripts/check-runtime.ts", + "check:installed-lock-parity": "node scripts/check-installed-lock-parity.mjs", "check:codex-autofix-workflow": "node scripts/check-codex-autofix-workflow.mjs", "check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs", "check:edge:functions": "node scripts/check-edge-functions.mjs", diff --git a/scripts/check-installed-lock-parity.mjs b/scripts/check-installed-lock-parity.mjs new file mode 100644 index 0000000000..adcdee1171 --- /dev/null +++ b/scripts/check-installed-lock-parity.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const criticalInstalledPackages = ["next", "react", "react-dom", "playwright", "typescript"]; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +export function installedLockParity(projectRoot, packageNames = criticalInstalledPackages) { + const lockPath = path.join(projectRoot, "package-lock.json"); + const lock = readJson(lockPath); + + return packageNames.map((packageName) => { + const lockEntry = lock.packages?.[`node_modules/${packageName}`]; + const installedPath = path.join(projectRoot, "node_modules", ...packageName.split("/"), "package.json"); + let installedVersion = null; + let installedError = null; + + try { + installedVersion = readJson(installedPath).version ?? null; + } catch (error) { + installedError = error instanceof Error ? error.message : String(error); + } + + const lockedVersion = lockEntry?.version ?? null; + return { + packageName, + lockedVersion, + installedVersion, + ok: Boolean(lockedVersion && installedVersion && lockedVersion === installedVersion), + reason: !lockedVersion + ? "package is missing from package-lock.json" + : !installedVersion + ? `installed package is unavailable (${installedError ?? "unknown read error"})` + : lockedVersion !== installedVersion + ? `installed ${installedVersion} does not match locked ${lockedVersion}` + : null, + }; + }); +} + +function parseArgs(args) { + const options = { json: false, root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--root") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--root requires a project directory."); + options.root = path.resolve(value); + index += 1; + continue; + } + if (token === "--help" || token === "-h") { + console.log("Usage: npm run check:installed-lock-parity -- [--json] [--root directory]"); + process.exit(0); + } + throw new Error(`Unknown option: ${token}`); + } + return options; +} + +function isDirectExecution() { + return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +} + +if (isDirectExecution()) { + const options = parseArgs(process.argv.slice(2)); + const results = installedLockParity(options.root); + const failures = results.filter((result) => !result.ok); + + if (options.json) { + console.log(JSON.stringify({ ok: failures.length === 0, packages: results }, null, 2)); + } else { + for (const result of results) { + if (result.ok) console.log(`[installed-lock-parity] ${result.packageName}: ${result.lockedVersion}`); + else console.error(`[installed-lock-parity] ${result.packageName}: ${result.reason}`); + } + } + + if (failures.length > 0) { + console.error( + "Installed dependencies do not match package-lock.json. Run npm ci before interpreting test failures.", + ); + process.exit(1); + } +} diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs index a32af4735d..ab3076b92c 100644 --- a/scripts/check-maintainability-budgets.mjs +++ b/scripts/check-maintainability-budgets.mjs @@ -2,9 +2,9 @@ import { readFileSync } from "node:fs"; const budgets = new Map([ - // Raised with the mode-loading pass: leave-dashboard nav guards + scroll reset. - // Net client cost still fell — GlobalSearchShell now dynamic-imports this file. - ["src/components/ClinicalDashboard.tsx", 4160], + // Chrome ownership/reporting lives in use-dashboard-chrome-coordinator; keep + // the reclaimed monolith budget so it cannot silently drift back to 4160. + ["src/components/ClinicalDashboard.tsx", 4140], ["src/lib/rag/rag.ts", 5030], ["src/components/DocumentViewer.tsx", 1734], ["supabase/functions/indexing-v3-agent/index.ts", 2191], diff --git a/scripts/final-merge-audit.mjs b/scripts/final-merge-audit.mjs new file mode 100644 index 0000000000..8e9d68e5fe --- /dev/null +++ b/scripts/final-merge-audit.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const blockingMergeLabels = new Set(["hold", "do-not-merge"]); +export const requiredMergeChecks = new Map([["pr-required", new Set(["pr-required", "ci-pr-required"])]]); +const successfulCheckStates = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]); + +function normalizeCheckName(check) { + return String(check.name ?? check.context ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function run(executable, args, { json = false } = {}) { + const stdout = execFileSync(executable, args, { + cwd: projectRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); + return json ? JSON.parse(stdout) : stdout; +} + +function git(args) { + return run("git", args); +} + +function gh(args, options) { + return run("gh", args, options); +} + +/** + * @param {Record} snapshot + * @param {{ expectedHead?: string, postMerge?: boolean }} [options] + */ +export function validatePullRequestSnapshot(snapshot, { expectedHead, postMerge = false } = {}) { + const failures = []; + const labels = (snapshot.labels ?? []).map((label) => String(label.name ?? label).toLowerCase()); + const blockingLabels = labels.filter((label) => blockingMergeLabels.has(label)); + const checks = snapshot.statusCheckRollup ?? []; + const requiredChecks = checks.filter((check) => + [...requiredMergeChecks.values()].some((aliases) => aliases.has(normalizeCheckName(check))), + ); + const missingRequiredChecks = [...requiredMergeChecks].flatMap(([requiredName, aliases]) => + checks.some((check) => aliases.has(normalizeCheckName(check))) ? [] : [requiredName], + ); + const unsettledChecks = requiredChecks.filter((check) => { + const state = String(check.conclusion ?? check.state ?? check.status ?? "").toUpperCase(); + return !successfulCheckStates.has(state); + }); + + if (postMerge) { + if (snapshot.state !== "MERGED") failures.push(`PR state is ${snapshot.state ?? "unknown"}, expected MERGED`); + } else { + if (snapshot.state !== "OPEN") failures.push(`PR state is ${snapshot.state ?? "unknown"}, expected OPEN`); + if (snapshot.isDraft) failures.push("PR is still a draft"); + if (/^\s*wip\b|do\s+not\s+merge/i.test(snapshot.title ?? "")) failures.push("PR title marks the change as WIP"); + if (snapshot.mergeable !== "MERGEABLE") { + failures.push(`GitHub mergeability is ${snapshot.mergeable ?? "unknown"}, expected MERGEABLE`); + } + if (snapshot.mergeStateStatus !== "CLEAN") { + failures.push(`GitHub merge state is ${snapshot.mergeStateStatus ?? "unknown"}, expected CLEAN`); + } + if (missingRequiredChecks.length > 0) { + failures.push(`required check(s) missing: ${missingRequiredChecks.join(", ")}`); + } + if (unsettledChecks.length > 0) failures.push(`${unsettledChecks.length} check(s) are not settled successfully`); + if (blockingLabels.length > 0) failures.push(`blocking label(s): ${blockingLabels.join(", ")}`); + } + if (expectedHead && snapshot.headRefOid !== expectedHead) { + failures.push(`PR head ${snapshot.headRefOid ?? "unknown"} does not match expected head ${expectedHead}`); + } + + return { failures, blockingLabels, missingRequiredChecks, requiredChecks, unsettledChecks }; +} + +function parseArgs(args) { + const options = { + baseRef: "origin/main", + headRef: "HEAD", + dryRun: false, + providers: false, + postMerge: false, + pr: undefined, + repo: undefined, + expectedHead: undefined, + expectedTree: undefined, + healthUrl: undefined, + }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--dry-run") options.dryRun = true; + else if (token === "--providers") options.providers = true; + else if (token === "--post-merge") options.postMerge = true; + else if ( + ["--base-ref", "--head-ref", "--pr", "--repo", "--expected-head", "--expected-tree", "--health-url"].includes( + token, + ) + ) { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${token} requires a value.`); + const key = token.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + options[key] = token === "--pr" ? Number(value) : value; + index += 1; + } else if (token === "--help" || token === "-h") { + console.log( + "Usage: npm run audit:final-merge -- [--dry-run] [--base-ref ref] [--head-ref ref] [--expected-head sha]\n" + + " [--providers --pr number --repo owner/name] [--post-merge --expected-tree tree --health-url url]\n" + + "Provider reads require ALLOW_PROVIDER_READS=true. The audit never merges, pushes, reruns CI, or deploys.", + ); + process.exit(0); + } else throw new Error(`Unknown option: ${token}`); + } + + if (options.postMerge) options.providers = true; + if (options.providers && (!Number.isInteger(options.pr) || options.pr <= 0)) { + throw new Error("--providers requires a positive --pr number."); + } + if (options.postMerge && !options.expectedTree) throw new Error("--post-merge requires --expected-tree."); + return options; +} + +function repositoryFromOrigin() { + const remote = git(["remote", "get-url", "origin"]); + const match = remote.match(/(?:github\.com[/:])([^/]+)\/([^/.]+)(?:\.git)?$/i); + if (!match) throw new Error("Could not infer a GitHub owner/name from origin; pass --repo owner/name."); + return `${match[1]}/${match[2]}`; +} + +function localAudit(options) { + const failures = []; + const baseSha = git(["rev-parse", `${options.baseRef}^{commit}`]); + const headSha = git(["rev-parse", `${options.headRef}^{commit}`]); + const mergeBase = git(["merge-base", baseSha, headSha]); + let mergeTree = null; + try { + mergeTree = git(["merge-tree", "--write-tree", baseSha, headSha]).split(/\r?\n/)[0]; + } catch { + failures.push("local merge-tree could not be produced cleanly"); + } + if (!options.postMerge && mergeBase !== baseSha) + failures.push(`${options.headRef} does not contain ${options.baseRef}`); + if (options.expectedHead && headSha !== options.expectedHead) { + failures.push(`local head ${headSha} does not match expected head ${options.expectedHead}`); + } + if (!options.postMerge && git(["status", "--porcelain"])) failures.push("worktree has uncommitted changes"); + return { baseSha, headSha, mergeBase, mergeTree, failures }; +} + +async function providerAudit(options, local) { + if (process.env.ALLOW_PROVIDER_READS !== "true") { + throw new Error("Provider audit requires explicit ALLOW_PROVIDER_READS=true confirmation."); + } + + const repo = options.repo ?? repositoryFromOrigin(); + const [owner, name] = repo.split("/"); + if (!owner || !name) throw new Error("--repo must use owner/name format."); + const snapshot = gh( + [ + "pr", + "view", + String(options.pr), + "--repo", + repo, + "--json", + "number,state,isDraft,title,baseRefName,headRefName,headRefOid,mergeStateStatus,mergeable,labels,statusCheckRollup,mergeCommit,url", + ], + { json: true }, + ); + const expectedHead = options.expectedHead ?? local.headSha; + const validation = validatePullRequestSnapshot(snapshot, { expectedHead, postMerge: options.postMerge }); + const failures = [...validation.failures]; + const expectedBaseName = options.baseRef.replaceAll("\\", "/").split("/").at(-1); + if (snapshot.baseRefName !== expectedBaseName) { + failures.push(`PR base ${snapshot.baseRefName ?? "unknown"} does not match expected base ${expectedBaseName}`); + } + + const remoteBase = gh(["api", `repos/${repo}/git/ref/heads/${snapshot.baseRefName}`], { json: true }).object?.sha; + if (!remoteBase) failures.push("remote base SHA was unavailable"); + else if (!options.postMerge && remoteBase !== local.baseSha) { + failures.push(`local ${options.baseRef} ${local.baseSha} is stale; remote base is ${remoteBase}`); + } + + const query = `query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved}pageInfo{hasNextPage}}}}}`; + const threadPayload = gh( + [ + "api", + "graphql", + "-f", + `query=${query}`, + "-F", + `owner=${owner}`, + "-F", + `name=${name}`, + "-F", + `number=${options.pr}`, + ], + { json: true }, + ); + const unresolvedThreads = + threadPayload.data?.repository?.pullRequest?.reviewThreads?.nodes?.filter((thread) => !thread.isResolved).length ?? + 0; + if (threadPayload.data?.repository?.pullRequest?.reviewThreads?.pageInfo?.hasNextPage) { + failures.push("review-thread audit exceeded 100 threads; paginate before merging"); + } + if (!options.postMerge && unresolvedThreads > 0) failures.push(`${unresolvedThreads} unresolved review thread(s)`); + + let remoteTree = null; + if (options.postMerge && remoteBase) { + remoteTree = gh(["api", `repos/${repo}/git/commits/${remoteBase}`], { json: true }).tree?.sha ?? null; + if (remoteTree !== options.expectedTree) { + failures.push(`remote main tree ${remoteTree ?? "unknown"} does not match expected tree ${options.expectedTree}`); + } + } + + let health = null; + if (options.healthUrl) { + const response = await fetch(options.healthUrl, { redirect: "follow", signal: AbortSignal.timeout(20_000) }); + const payload = await response.json().catch(() => null); + health = { status: response.status, ok: response.ok, appStatus: payload?.status ?? null }; + if (!response.ok) failures.push(`deployment health returned HTTP ${response.status}`); + else if (payload?.status !== "ok") failures.push(`deployment health status is ${payload?.status ?? "unavailable"}`); + } else if (options.postMerge) failures.push("post-merge audit requires --health-url for deployment proof"); + + return { repo, snapshot, remoteBase, remoteTree, unresolvedThreads, health, failures }; +} + +async function main(args) { + const options = parseArgs(args); + const local = localAudit(options); + console.log( + JSON.stringify( + { + phase: options.postMerge ? "post-merge" : "pre-merge", + local: { + baseSha: local.baseSha, + headSha: local.headSha, + mergeBase: local.mergeBase, + expectedMergeTree: local.mergeTree, + failures: local.failures, + }, + providerPlan: options.providers + ? ["fresh remote base/head", "required checks", "review threads", "labels", "remote tree equality", "health"] + : "not requested", + }, + null, + 2, + ), + ); + + if (options.dryRun) return local.failures.length ? 1 : 0; + + const provider = options.providers ? await providerAudit(options, local) : null; + if (provider) { + console.log( + JSON.stringify( + { + repo: provider.repo, + pr: provider.snapshot.number, + url: provider.snapshot.url, + remoteBase: provider.remoteBase, + remoteTree: provider.remoteTree, + unresolvedThreads: provider.unresolvedThreads, + health: provider.health, + failures: provider.failures, + }, + null, + 2, + ), + ); + } + + const failures = [...local.failures, ...(provider?.failures ?? [])]; + if (failures.length > 0) { + console.error("Final merge audit failed:"); + for (const failure of failures) console.error(`- ${failure}`); + return 1; + } + console.log("Final merge audit passed."); + return 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = await main(process.argv.slice(2)); +} diff --git a/scripts/phone-chrome-plan.mjs b/scripts/phone-chrome-plan.mjs new file mode 100644 index 0000000000..27f18040d9 --- /dev/null +++ b/scripts/phone-chrome-plan.mjs @@ -0,0 +1,202 @@ +const normalize = (file) => file.replaceAll("\\", "/").replace(/^\.\//, ""); + +const phoneChromeContractTests = [ + "tests/installed-lock-parity.test.ts", + "tests/final-merge-audit.test.ts", + "tests/verify-phone-chrome.test.ts", + "tests/verify-pr-local.test.ts", + "tests/header-scroll-hide-contract.test.ts", + "tests/mobile-composer-reserve.test.ts", + "tests/ui-overlay-css-contract.test.ts", + "tests/clinical-dashboard-merge-artifacts.test.ts", + "tests/use-hide-on-scroll.test.ts", +]; + +const phoneChromeBrowserSpecPattern = + /^tests\/ui-(?:phone-scroll|smoke|tools|chrome-scroll|therapy-nav-scroll)\.spec\.ts$/; + +const patterns = { + docs: [/^docs\//, /^AGENTS\.md$/], + infrastructure: [ + /^package(?:-lock)?\.json$/, + /^scripts\/(?:check-installed-lock-parity|phone-chrome-plan|verify-phone-chrome)\.mjs$/, + /^tests\/(?:installed-lock-parity|verify-phone-chrome)\.test\.ts$/, + ], + playwrightHelper: [/^tests\/playwright-(?:scroll|settlement)\.ts$/], + dashboard: [ + /^src\/components\/ClinicalDashboard\.tsx$/, + /^src\/components\/clinical-dashboard\/use-dashboard-chrome-coordinator\.ts$/, + /^tests\/ui-smoke\.spec\.ts$/, + ], + shell: [ + /^src\/components\/clinical-dashboard\/(?:global-search-shell|master-search-header)\.tsx$/, + /^tests\/ui-tools\.spec\.ts$/, + ], + documents: [ + /^src\/components\/DocumentViewer\.tsx$/, + /^src\/components\/clinical-dashboard\/use-document-viewer-chrome-scroll\.ts$/, + ], + calculators: [/^src\/components\/calculators\//], + differentials: [/^src\/components\/differentials\//], + sharedFoundation: [ + /^src\/app\/globals\.css$/, + /^src\/styles\//, + /^src\/components\/ClinicalDashboard\.tsx$/, + /^src\/components\/clinical-dashboard\/(?:global-search-shell|master-search-header|mobile-composer-reserve|phone-footer-layer-portal|scroll-surface|use-active-scroll-owner|use-dashboard-chrome-coordinator|use-hide-on-scroll)\.(?:ts|tsx)$/, + /^package-lock\.json$/, + ], + phoneContract: [ + /^docs\/(?:search-chrome-behaviour|phone-chrome-physical-acceptance)\.md$/, + /^tests\/(?:header-scroll-hide-contract|mobile-composer-reserve|ui-overlay-css-contract|clinical-dashboard-merge-artifacts|use-hide-on-scroll)\.test\.ts$/, + /^tests\/ui-(?:phone-scroll|chrome-scroll|therapy-nav-scroll)\.spec\.ts$/, + ], +}; + +function matchesAny(file, matchers) { + return matchers.some((pattern) => pattern.test(file)); +} + +function nodeStage(id, label, args) { + return { id, label, command: { executable: "node", args } }; +} + +function npmStage(id, label, script) { + return { id, label, command: { executable: "npm", args: ["run", script] } }; +} + +export function renderPhoneChromeCommand(command) { + return [ + command.executable, + ...command.args.map((argument) => (/\s|\|/.test(argument) ? JSON.stringify(argument) : argument)), + ].join(" "); +} + +export function phoneChromePlan(rawFiles, { fullMode = "auto" } = {}) { + if (!new Set(["auto", "always", "never"]).has(fullMode)) { + throw new Error(`Unsupported full-suite mode: ${fullMode}`); + } + + const files = [...new Set(rawFiles.map(normalize).filter(Boolean))].sort(); + const flags = Object.fromEntries( + Object.entries(patterns).map(([name, matchers]) => [name, files.some((file) => matchesAny(file, matchers))]), + ); + const docsOnly = files.length > 0 && files.every((file) => matchesAny(file, patterns.docs)); + const uiSourceChanged = files.some((file) => /^src\/(?:app|components|styles)\//.test(file)); + const phoneRelevant = + flags.infrastructure || + flags.playwrightHelper || + flags.dashboard || + flags.shell || + flags.documents || + flags.calculators || + flags.differentials || + flags.sharedFoundation || + flags.phoneContract; + const unknownUi = uiSourceChanged && !phoneRelevant; + const runOwnership = + !docsOnly && + (flags.playwrightHelper || + flags.dashboard || + flags.shell || + flags.documents || + flags.calculators || + flags.differentials || + flags.sharedFoundation || + flags.phoneContract); + const runDashboardJourneys = + !docsOnly && (flags.playwrightHelper || flags.dashboard || flags.documents || flags.sharedFoundation); + const runShellJourneys = !docsOnly && (flags.playwrightHelper || flags.shell || flags.sharedFoundation); + const autoFull = Boolean(!docsOnly && (flags.sharedFoundation || unknownUi)); + const runFull = fullMode === "always" || (fullMode === "auto" && autoFull); + + const stages = []; + + if (docsOnly) { + stages.push(npmStage("docs-index", "documentation remains indexed", "docs:check-index")); + stages.push(npmStage("docs-links", "documentation links remain valid", "docs:check-links")); + } else { + stages.push( + npmStage("lock-parity", "critical installed packages match package-lock.json", "check:installed-lock-parity"), + ); + } + + if (!docsOnly && (phoneRelevant || unknownUi)) { + stages.push(npmStage("runtime", "Node and npm runtime match the repository contract", "check:runtime")); + stages.push( + nodeStage("contracts", "phone chrome static and unit contracts", [ + "scripts/run-vitest.mjs", + "run", + ...phoneChromeContractTests, + "--reporter=dot", + ]), + ); + } + + const changedBrowserFiles = files.filter((file) => phoneChromeBrowserSpecPattern.test(file)); + const changedBrowserFileSet = new Set(changedBrowserFiles); + if (changedBrowserFiles.length > 0) { + stages.push( + nodeStage("changed-browser", "complete changed phone-chrome browser specs", [ + "scripts/run-playwright.mjs", + ...changedBrowserFiles, + "--project=chromium", + ]), + ); + } + + const focusedBrowserJourneys = []; + if (runOwnership && !changedBrowserFileSet.has("tests/ui-phone-scroll.spec.ts")) { + focusedBrowserJourneys.push({ + file: "tests/ui-phone-scroll.spec.ts", + pattern: + "phone browser results use document scrolling|document detail header and footer follow Safari document scrolling together|compiled standalone PWA rules bind full-height footer chrome|standalone .* is frame-owned", + }); + } + if (runDashboardJourneys && !changedBrowserFileSet.has("tests/ui-smoke.spec.ts")) { + focusedBrowserJourneys.push({ + file: "tests/ui-smoke.spec.ts", + pattern: + "phone long answer stays scrollable|phone answer result keeps the edge dock|answer glass header overlays main|document viewer bottom composer hides", + }); + } + if (runShellJourneys && !changedBrowserFileSet.has("tests/ui-tools.spec.ts")) { + focusedBrowserJourneys.push({ + file: "tests/ui-tools.spec.ts", + pattern: "phone bottom search dock stays edge-to-edge|phone bottom search dock hides while scrolling down", + }); + } + if (focusedBrowserJourneys.length > 0) { + stages.push( + nodeStage("focused-browser", "browser/PWA ownership matrix and affected phone journeys", [ + "scripts/run-playwright.mjs", + ...focusedBrowserJourneys.map(({ file }) => file), + "--project=chromium", + "--grep", + focusedBrowserJourneys.map(({ pattern }) => pattern).join("|"), + ]), + ); + } + if (runFull) stages.push(npmStage("full-ui", "full Chromium UI suite after focused phone proof", "verify:ui")); + + return { + files, + stages, + phoneRelevant: Boolean(phoneRelevant || unknownUi), + fullMode, + fullRecommended: autoFull, + fullSelected: runFull, + notes: [ + !phoneRelevant && !unknownUi + ? "No phone-chrome-affecting file was detected; only prerequisite checks were selected." + : null, + fullMode === "never" && autoFull + ? "The full UI suite is recommended for these shared-foundation changes but was explicitly disabled." + : null, + fullMode === "auto" && !autoFull && !docsOnly + ? "Focused ownership and journey coverage is sufficient for this page-local or test-infrastructure scope." + : null, + ].filter(Boolean), + }; +} + +export const phoneChromePlanInternals = { patterns }; diff --git a/scripts/verify-phone-chrome.mjs b/scripts/verify-phone-chrome.mjs new file mode 100644 index 0000000000..9d5e59bfe3 --- /dev/null +++ b/scripts/verify-phone-chrome.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import { execFileSync, spawnSync } from "node:child_process"; +import { childProcessExitCode } from "./child-process-result.mjs"; +import { phoneChromePlan, renderPhoneChromeCommand } from "./phone-chrome-plan.mjs"; + +function parseArgs(args) { + const options = { dryRun: false, files: undefined, fullMode: "auto" }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--files") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--files requires a comma-separated path list."); + options.files = value; + index += 1; + continue; + } + if (token.startsWith("--full=")) { + options.fullMode = token.slice("--full=".length); + continue; + } + if (token === "--help" || token === "-h") { + console.log( + "Usage: npm run verify:phone-chrome -- [--dry-run] [--files pathA,pathB] [--full=auto|always|never]\n" + + " --dry-run Print the selected stages without executing them.\n" + + " --files Classify an explicit comma-separated changed-file list.\n" + + " --full=auto Run verify:ui only for shared chrome foundations (default).", + ); + process.exit(0); + } + throw new Error(`Unknown option: ${token}`); + } + return options; +} + +function changedFiles(explicitFiles) { + if (explicitFiles) + return explicitFiles + .split(",") + .map((file) => file.trim()) + .filter(Boolean); + const result = execFileSync(process.execPath, ["scripts/ci-change-scope.mjs", "--json"], { encoding: "utf8" }); + return JSON.parse(result).files; +} + +function run(command) { + const executable = process.platform === "win32" && command.executable === "npm" ? "cmd.exe" : command.executable; + const args = + executable === "cmd.exe" ? ["/d", "/s", "/c", [command.executable, ...command.args].join(" ")] : command.args; + const result = spawnSync(executable, args, { stdio: "inherit" }); + return childProcessExitCode(result); +} + +const options = parseArgs(process.argv.slice(2)); +const plan = phoneChromePlan(changedFiles(options.files), { fullMode: options.fullMode }); +console.log(`Phone chrome inputs: ${plan.files.length ? plan.files.join(", ") : "(none detected)"}`); +console.log(`Full UI policy: ${plan.fullMode} (${plan.fullSelected ? "selected" : "not selected"})`); +for (const note of plan.notes) console.log(`Note: ${note}`); + +if (options.dryRun) { + console.log("\nPhone chrome verification plan (dry run):"); + for (const stage of plan.stages) console.log(`- [${stage.id}] ${renderPhoneChromeCommand(stage.command)}`); + process.exit(0); +} + +for (const stage of plan.stages) { + console.log(`\n[phone-chrome:${stage.id}] ${stage.label}`); + console.log(`> ${renderPhoneChromeCommand(stage.command)}`); + const exitCode = run(stage.command); + if (exitCode !== 0) process.exit(exitCode); +} diff --git a/scripts/verify-pr-local.mjs b/scripts/verify-pr-local.mjs index 883cc6e24b..a9970244f7 100644 --- a/scripts/verify-pr-local.mjs +++ b/scripts/verify-pr-local.mjs @@ -4,7 +4,7 @@ import { childProcessExitCode } from "./child-process-result.mjs"; const isWindows = process.platform === "win32"; // Live Supabase audits (check:locality-metadata) stay out of this unconditional gate. -const baseScripts = ["check:runtime", "format:changed", "lint", "typecheck", "test"]; +const baseScripts = ["check:runtime", "check:installed-lock-parity", "format:changed", "lint", "typecheck", "test"]; function parseArgs(args) { const options = { dryRun: false, extended: false, files: undefined }; diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 29cd4a7ad5..800646e5a3 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -89,12 +89,7 @@ import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/univ import { FavouritesGuestGate } from "@/components/clinical-dashboard/favourites-guest-gate"; import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions"; import { focusComposerInput as scheduleComposerFocus } from "@/components/clinical-dashboard/focus-composer-input"; -import { - readChromeCollapseMetrics, - useDocumentScrollHideReporter, - useReserveTransitionMarker, - useScrollHideReporter, -} from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { useDashboardChromeCoordinator } from "@/components/clinical-dashboard/use-dashboard-chrome-coordinator"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerReferencesDocument, @@ -325,13 +320,6 @@ export function ClinicalDashboard({ const router = useRouter(); const searchParams = useSearchParams(); const [initialSearchNavigationContext] = useState(() => readSearchNavigationContext(searchParams)); - const mainRef = useRef(null); - const [mainScrollRoot, setMainScrollRoot] = useState(null); - const assignMainRef = useCallback((node: HTMLElement | null) => { - mainRef.current = node; - setMainScrollRoot(node); - }, []); - const composerInputRef = useRef(null); const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); const autoRunSearchSignatureRef = useRef(null); @@ -363,15 +351,21 @@ export function ClinicalDashboard({ const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false); const [query, setQuery] = useState(initialQuery); const [searchMode, setSearchMode] = useState(initialSearchMode); - // The header hides at every breakpoint in every mode (answer mode through the - // all-breakpoints glass overlay, the rest through the collapse row). Switching - // mode swaps
's header reserve, so it also rebases the reporter. - const chromeScrollHide = useScrollHideReporter(false, true, searchMode); - const [bottomComposerHidden, setBottomComposerHidden] = useState(false); - const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, searchMode); - const chromeTransitioning = useReserveTransitionMarker(chromeScrollHide.hidden, searchMode); - const reportChromeScrollHide = useEventCallback(chromeScrollHide.reportScroll); - useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainScrollRoot, composerInputRef); + const { + activeScrollOwner, + assignMainRef, + bottomComposerHidden, + chromeScrollHidden, + chromeTransitioning, + composerInputRef, + mainRef, + reserveTransitioning, + setBottomComposerHidden, + } = useDashboardChromeCoordinator(searchMode); + const focusComposerInput = useCallback( + (retainTarget = false) => scheduleComposerFocus(composerInputRef, retainTarget), + [composerInputRef], + ); const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); @@ -447,7 +441,7 @@ export function ClinicalDashboard({ window.requestAnimationFrame(() => { scrollSurface(mainRef.current, "end"); }); - }, [answer]); + }, [answer, mainRef]); const resetAnswerThread = useCallback(() => { setPriorAnswerTurns([]); setLatestAnswerQuery(null); @@ -522,6 +516,41 @@ export function ClinicalDashboard({ const [pendingFeedback, setPendingFeedback] = useState(null); const [actionNotice, setActionNotice] = useState<{ tone: "success" | "warning"; message: string } | null>(null); const [activeHash, setActiveHash] = useState("#search"); + const navigateMobileSection = useCallback( + (href: string, options: { updateHistory?: boolean } = {}) => { + const shouldUpdateHistory = options.updateHistory ?? true; + const main = mainRef.current; + if (!main) return; + + if (navSyncLockRef.current !== null) { + window.clearTimeout(navSyncLockRef.current); + } + + if (href === "#search") { + setActiveHash(href); + scrollSurface(main, 0); + if (shouldUpdateHistory) window.history.replaceState(null, "", href); + navSyncLockRef.current = window.setTimeout(() => { + navSyncLockRef.current = null; + }, 350); + return; + } + + const target = document.querySelector(href); + if (!target) return; + setActiveHash(href); + const targetTop = target.getBoundingClientRect().top; + const top = ownsVerticalScroll(main) + ? main.scrollTop + targetTop - main.getBoundingClientRect().top - 8 + : window.scrollY + targetTop - 8; + scrollSurface(main, top); + if (shouldUpdateHistory) window.history.replaceState(null, "", href); + navSyncLockRef.current = window.setTimeout(() => { + navSyncLockRef.current = null; + }, 350); + }, + [mainRef], + ); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -1528,7 +1557,7 @@ export function ClinicalDashboard({ focusComposerInput(true); const timeout = window.setTimeout(() => focusComposerInput(true), 500); return () => window.clearTimeout(timeout); - }, [shouldAutoFocusComposer]); + }, [composerInputRef, focusComposerInput, shouldAutoFocusComposer]); // Abort any in-flight answer/library search if the dashboard unmounts. useEffect(() => { @@ -1568,7 +1597,7 @@ export function ClinicalDashboard({ if (shouldFocusComposer) focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); - }, [searchParams, clearDifferentialModeResultState]); + }, [searchParams, clearDifferentialModeResultState, focusComposerInput]); useEffect(() => { if (urlSearchBootstrappedRef.current) return; @@ -1589,7 +1618,7 @@ export function ClinicalDashboard({ if (shouldFocusComposer && params.get("run") !== "1") focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); - }, [clearDifferentialModeResultState]); + }, [clearDifferentialModeResultState, focusComposerInput]); const executeSearchRef = useRef(executeSearch); executeSearchRef.current = executeSearch; @@ -1645,7 +1674,7 @@ export function ClinicalDashboard({ updateHash(); window.addEventListener("hashchange", updateHash); return () => window.removeEventListener("hashchange", updateHash); - }, []); + }, [navigateMobileSection]); useEffect(() => { return () => { @@ -2671,10 +2700,6 @@ export function ClinicalDashboard({ }); } - function focusComposerInput(retainTarget = false) { - scheduleComposerFocus(composerInputRef, retainTarget); - } - function stageAnswerFollowUpDraft(draft: string) { setQuery(draft); focusComposerInput(); @@ -2775,39 +2800,6 @@ export function ClinicalDashboard({ }); } - function navigateMobileSection(href: string, options: { updateHistory?: boolean } = {}) { - const shouldUpdateHistory = options.updateHistory ?? true; - const main = mainRef.current; - if (!main) return; - - if (navSyncLockRef.current !== null) { - window.clearTimeout(navSyncLockRef.current); - } - - if (href === "#search") { - setActiveHash(href); - scrollSurface(main, 0); - if (shouldUpdateHistory) window.history.replaceState(null, "", href); - navSyncLockRef.current = window.setTimeout(() => { - navSyncLockRef.current = null; - }, 350); - return; - } - - const target = document.querySelector(href); - if (!target) return; - setActiveHash(href); - const targetTop = target.getBoundingClientRect().top; - const top = ownsVerticalScroll(main) - ? main.scrollTop + targetTop - main.getBoundingClientRect().top - 8 - : window.scrollY + targetTop - 8; - scrollSurface(main, top); - if (shouldUpdateHistory) window.history.replaceState(null, "", href); - navSyncLockRef.current = window.setTimeout(() => { - navSyncLockRef.current = null; - }, 350); - } - const syncActiveSectionFromScroll = useCallback(() => { const main = mainRef.current; if (!main) return; @@ -2835,7 +2827,7 @@ export function ClinicalDashboard({ .filter((item): item is { section: string; distance: number } => Boolean(item)) .sort((a, b) => a.distance - b.distance)[0]?.section ?? "#search"; setActiveHash((active) => (active === current ? active : current)); - }, []); + }, [mainRef]); const scheduleActiveSectionSync = useCallback(() => { if (scrollFrameRef.current !== null) return; @@ -2854,32 +2846,6 @@ export function ClinicalDashboard({ return () => window.removeEventListener("scroll", scheduleActiveSectionSync); }, [scheduleActiveSectionSync]); - useEffect(() => { - const main = mainScrollRoot; - if (!main) return undefined; - - let frame = 0; - const onScroll = () => { - if (frame) return; - frame = window.requestAnimationFrame(() => { - frame = 0; - reportChromeScrollHide({ - offset: main.scrollTop, - maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), - ...readChromeCollapseMetrics(main), - source: main, - }); - }); - }; - - onScroll(); - main.addEventListener("scroll", onScroll, { passive: true }); - return () => { - main.removeEventListener("scroll", onScroll); - if (frame) window.cancelAnimationFrame(frame); - }; - }, [mainScrollRoot, reportChromeScrollHide]); - async function copyText(action: string, text: string) { let copied = false; try { @@ -3351,7 +3317,10 @@ export function ClinicalDashboard({ onPrefetchApplications={prefetchApplications} showAccountLibrary={favouritesAccessible} /> - + @@ -3436,6 +3405,12 @@ export function ClinicalDashboard({ data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} data-reserve-transitioning={reserveTransitioning ? "true" : undefined} data-chrome-transitioning={chromeTransitioning ? "true" : undefined} + data-phone-scroll-owner={activeScrollOwner} + data-phone-footer-owner={ + heroOwnsPhoneComposer ? "hero" : searchMode === "answer" || hasMobileBottomSearch ? "dashboard" : "none" + } + data-phone-composer-reserve={mobileComposerReserve} + data-phone-chrome-transition={reserveTransitioning || chromeTransitioning ? "active" : "idle"} className={cn( "phone-scroll-surface min-h-0 flex-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:overflow-x-hidden sm:overflow-y-auto sm:overscroll-contain sm:[-webkit-overflow-scrolling:touch]", // Answer view: the glass header is absolute over this surface, so diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index b3b3194f3e..d0247c7a20 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -21,6 +21,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; +import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner"; import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal"; import { PdfPreviewLoading } from "@/components/document-viewer/pdf-preview-loading"; import { @@ -295,6 +296,7 @@ export function DocumentViewer({ mobileActionsOpen, composerChromeFocused, ); + const activeScrollOwner = useActiveScrollOwner(shellScrollContainer, documentId); const [useNativePdfViewer, setUseNativePdfViewer] = useState(getDefaultPdfViewerMode); const [hasExplicitPdfViewerMode, setHasExplicitPdfViewerMode] = useState(false); const [viewerModeInitialized, setViewerModeInitialized] = useState(false); @@ -1210,6 +1212,12 @@ export function DocumentViewer({ data-testid="document-viewer-content" data-scroll-hidden={composerScrollHidden ? "true" : undefined} data-reserve-transitioning={reserveTransitioning ? "true" : undefined} + data-phone-scroll-owner={activeScrollOwner} + data-phone-footer-owner={readyDocument ? "document-viewer" : "none"} + data-phone-composer-reserve={ + composerScrollHidden ? "0rem" : "calc(9rem + var(--safe-area-bottom) + var(--keyboard-height, 0px))" + } + data-phone-chrome-transition={reserveTransitioning ? "active" : "idle"} className={cn( "mx-auto grid max-w-[1440px] gap-4 px-3 py-4 sm:gap-5 sm:px-4 sm:py-5 sm:pb-40 lg:grid-cols-[minmax(0,1fr)_480px] lg:items-start lg:px-8", // The visible fixed composer needs endpoint clearance. Once hidden, diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 6c55216a12..693ae166a4 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -19,7 +19,10 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; -import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; +import { + PhoneFooterLayerPortal, + usePhoneFooterLayerScrollHidden, +} from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { useHideOnScroll, useReserveTransitionMarker } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; @@ -503,19 +506,27 @@ export function CalculatorsSearchPage() { }; }, [activeCalc]); - // Hide the bottom composer dock on scroll-down in lockstep with the shell's - // top header, using the same hook (identical thresholds, phone-only, inert on - // desktop). Browser phones scroll the document so Safari can minimize its - // chrome; standalone mode keeps #main-content as its bounded scroller. Feed - // both possible owners and combine them so this page-owned dock follows the - // same active source as the shell header. + // The viewport frame owns the authoritative hide decision for its header and + // every portaled footer. Reuse that signal so slower hydration/RAF scheduling + // cannot let independently evaluated header and calculator reporters split. + // Keep local reporters only for the shell-less fallback used by isolated + // renders; browser phones scroll the document while standalone mode scrolls + // #main-content. + const frameScrollHidden = usePhoneFooterLayerScrollHidden(); + const useLocalScrollFallback = frameScrollHidden === undefined; const scrollContainerRef = useRef(null); useEffect(() => { scrollContainerRef.current = document.querySelector("#main-content"); }, []); - const innerFooterHidden = useHideOnScroll({ containerRef: scrollContainerRef }); - const documentFooterHidden = useHideOnScroll({ documentCollapseRootRef: scrollContainerRef }); - const footerHidden = innerFooterHidden || documentFooterHidden; + const innerFooterHidden = useHideOnScroll({ + containerRef: scrollContainerRef, + disabled: !useLocalScrollFallback, + }); + const documentFooterHidden = useHideOnScroll({ + documentCollapseRootRef: scrollContainerRef, + disabled: !useLocalScrollFallback, + }); + const footerHidden = frameScrollHidden ?? (innerFooterHidden || documentFooterHidden); // Keep the phone dock visible while focused so scroll-hide cannot slide a // focused input off-screen or mark it aria-hidden while still tabbable. const [dockFocused, setDockFocused] = useState(false); diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index c91386caca..a6123b0cc0 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -30,6 +30,7 @@ import { landingModeForPreference, readAppPreferences } from "@/components/clini import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal"; +import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner"; import { isPageOwnedComposerRoute, resolveMobileComposerReserve, @@ -331,6 +332,7 @@ function GlobalStandaloneSearchShellBody({ } const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, pathname); const chromeTransitioning = useReserveTransitionMarker(chromeScrollHide.hidden, pathname); + const activeScrollOwner = useActiveScrollOwner(mainElement, pathname); useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainElement, inputRef); useEffect(() => { reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; @@ -755,7 +757,10 @@ function GlobalStandaloneSearchShellBody({ ) : null} - + {/* `contents` at every visible breakpoint: the chrome wrapper pins itself to the viewport top, and a plain block here would be a header-height @@ -864,6 +869,18 @@ function GlobalStandaloneSearchShellBody({ data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} data-reserve-transitioning={reserveTransitioning ? "true" : undefined} data-chrome-transitioning={chromeTransitioning ? "true" : undefined} + data-phone-scroll-owner={activeScrollOwner} + data-phone-footer-owner={ + isStandaloneModeHome + ? "hero" + : isPageOwnedComposerRoute(pathname) + ? "page" + : shouldShowSearchComposer + ? "shell" + : "none" + } + data-phone-composer-reserve={mobileComposerReserve} + data-phone-chrome-transition={reserveTransitioning || chromeTransitioning ? "active" : "idle"} className={cn( // Browser phones use overflow-x: clip so CSS cannot silently turn // overflow-y: visible into an element scroller. Standalone mode diff --git a/src/components/clinical-dashboard/phone-footer-layer-portal.tsx b/src/components/clinical-dashboard/phone-footer-layer-portal.tsx index 61090cd66c..3acf0d07e0 100644 --- a/src/components/clinical-dashboard/phone-footer-layer-portal.tsx +++ b/src/components/clinical-dashboard/phone-footer-layer-portal.tsx @@ -1,38 +1,60 @@ "use client"; -import { createContext, useCallback, useContext, useLayoutEffect, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; -const PhoneFooterLayerHostContext = createContext(null); +interface PhoneFooterLayerContextValue { + host: HTMLElement | null; + scrollHidden: boolean | undefined; +} + +const PhoneFooterLayerContext = createContext({ + host: null, + scrollHidden: undefined, +}); /** * Provides one paint-free footer host inside the current phone viewport frame. * The host is rendered after the frame's scroll surface so standalone footer * layers can anchor to the frame instead of scrolling with page content. */ -export function PhoneFooterLayerFrame({ children, className }: { children: ReactNode; className: string }) { +export function PhoneFooterLayerFrame({ + children, + className, + scrollHidden, +}: { + children: ReactNode; + className: string; + scrollHidden?: boolean; +}) { const [host, setHost] = useState(null); const assignHost = useCallback((node: HTMLDivElement | null) => { setHost((current) => (current === node ? current : node)); }, []); + const contextValue = useMemo(() => ({ host, scrollHidden }), [host, scrollHidden]); return ( - +
{children}
- + ); } +/** Authoritative frame scroll decision for page-owned phone footer layers. */ +export function usePhoneFooterLayerScrollHidden() { + return useContext(PhoneFooterLayerContext).scrollHidden; +} + /** * Moves a page-owned footer into the current viewport frame on phones. At sm+ * (and when rendered outside a shell) the same subtree remains inline, keeping * existing tablet/desktop placement and a safe shell-less fallback. */ export function PhoneFooterLayerPortal({ children }: { children: ReactNode }) { - const host = useContext(PhoneFooterLayerHostContext); + const { host } = useContext(PhoneFooterLayerContext); const [isPhone, setIsPhone] = useState(false); useLayoutEffect(() => { diff --git a/src/components/clinical-dashboard/use-active-scroll-owner.ts b/src/components/clinical-dashboard/use-active-scroll-owner.ts new file mode 100644 index 0000000000..109a046af9 --- /dev/null +++ b/src/components/clinical-dashboard/use-active-scroll-owner.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ownsVerticalScroll } from "@/components/clinical-dashboard/scroll-surface"; + +export type ActiveScrollOwner = "pending" | "main" | "document"; + +export function resolveActiveScrollOwner(element: HTMLElement | null): ActiveScrollOwner { + if (!element) return "pending"; + return ownsVerticalScroll(element) && element.scrollHeight > element.clientHeight + 1 ? "main" : "document"; +} + +/** + * Exposes the current phone scroll owner for deterministic browser diagnostics. + * Resize/content observers are frame-coalesced so streaming content and the + * browser/standalone media switch cannot leave a stale debug attribute. + */ +export function useActiveScrollOwner(element: HTMLElement | null, resetKey: unknown): ActiveScrollOwner { + const [owner, setOwner] = useState("pending"); + + useEffect(() => { + let frame = 0; + const update = () => { + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + const next = resolveActiveScrollOwner(element); + setOwner((current) => (current === next ? current : next)); + }); + }; + + if (!element) { + update(); + return () => { + if (frame) window.cancelAnimationFrame(frame); + }; + } + + const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); + const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(update); + const standaloneQuery = window.matchMedia("(display-mode: standalone)"); + + resizeObserver?.observe(element); + mutationObserver?.observe(element, { childList: true, subtree: true }); + element.addEventListener("scroll", update, { passive: true }); + window.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update, { passive: true }); + standaloneQuery.addEventListener?.("change", update); + update(); + + return () => { + if (frame) window.cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + element.removeEventListener("scroll", update); + window.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + standaloneQuery.removeEventListener?.("change", update); + }; + }, [element, resetKey]); + + return owner; +} diff --git a/src/components/clinical-dashboard/use-dashboard-chrome-coordinator.ts b/src/components/clinical-dashboard/use-dashboard-chrome-coordinator.ts new file mode 100644 index 0000000000..96890b63c1 --- /dev/null +++ b/src/components/clinical-dashboard/use-dashboard-chrome-coordinator.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner"; +import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback"; +import { + readChromeCollapseMetrics, + useDocumentScrollHideReporter, + useReserveTransitionMarker, + useScrollHideReporter, +} from "@/components/clinical-dashboard/use-hide-on-scroll"; + +export function useDashboardChromeCoordinator(resetKey: unknown) { + const mainRef = useRef(null); + const composerInputRef = useRef(null); + const [mainScrollRoot, setMainScrollRoot] = useState(null); + const [bottomComposerHidden, setBottomComposerHidden] = useState(false); + const chromeScrollHide = useScrollHideReporter(false, true, resetKey); + const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, resetKey); + const chromeTransitioning = useReserveTransitionMarker(chromeScrollHide.hidden, resetKey); + const reportChromeScrollHide = useEventCallback(chromeScrollHide.reportScroll); + const activeScrollOwner = useActiveScrollOwner(mainScrollRoot, resetKey); + + const assignMainRef = useCallback((node: HTMLElement | null) => { + mainRef.current = node; + setMainScrollRoot(node); + }, []); + + useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainScrollRoot, composerInputRef); + + useEffect(() => { + const main = mainScrollRoot; + if (!main) return undefined; + + let frame = 0; + const onScroll = () => { + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + reportChromeScrollHide({ + offset: main.scrollTop, + maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), + ...readChromeCollapseMetrics(main), + source: main, + }); + }); + }; + + onScroll(); + main.addEventListener("scroll", onScroll, { passive: true }); + return () => { + main.removeEventListener("scroll", onScroll); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [mainScrollRoot, reportChromeScrollHide]); + + return { + activeScrollOwner, + assignMainRef, + bottomComposerHidden, + chromeScrollHidden: chromeScrollHide.hidden, + chromeTransitioning, + composerInputRef, + mainRef, + reserveTransitioning, + setBottomComposerHidden, + }; +} diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index e9d48f01ec..df530aff3b 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -29,6 +29,10 @@ const globalSearchShellSource = readFileSync( "utf8", ); const clinicalDashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8"); +const dashboardChromeCoordinatorSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/use-dashboard-chrome-coordinator.ts"), + "utf8", +); const documentViewerSource = readFileSync(resolve(process.cwd(), "src/components/DocumentViewer.tsx"), "utf8"); const globalStylesSource = readFileSync(resolve(process.cwd(), "src/app/globals.css"), "utf8"); const uiPrimitivesSource = readFileSync(resolve(process.cwd(), "src/components/ui-primitives.tsx"), "utf8"); @@ -226,7 +230,8 @@ describe("ClinicalDashboard merge-artifact guards", () => { ); expect(globalStylesSource).toContain("transition: padding-bottom 240ms var(--ease-out-soft)"); expect(globalSearchShellSource).toContain("useReserveTransitionMarker"); - expect(clinicalDashboardSource).toContain("useReserveTransitionMarker"); + expect(clinicalDashboardSource).toContain("useDashboardChromeCoordinator(searchMode)"); + expect(dashboardChromeCoordinatorSource).toContain("useReserveTransitionMarker"); expect(globalStylesSource).toContain("--phone-dock-differentials-compare-clearance: 12.5rem"); expect(globalStylesSource).toContain("var(--phone-dock-differentials-compare-clearance)"); // Child pages must not stack a second dock-sized safe-area pad under the diff --git a/tests/final-merge-audit.test.ts b/tests/final-merge-audit.test.ts new file mode 100644 index 0000000000..9ce6246db3 --- /dev/null +++ b/tests/final-merge-audit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { validatePullRequestSnapshot } from "../scripts/final-merge-audit.mjs"; + +const greenSnapshot = { + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + title: "Improve phone chrome verification", + headRefOid: "abc123", + labels: [], + statusCheckRollup: [{ name: "PR required", conclusion: "SUCCESS" }], +}; + +describe("validatePullRequestSnapshot", () => { + it("accepts a settled, unlabelled PR at the expected head", () => { + expect(validatePullRequestSnapshot(greenSnapshot, { expectedHead: "abc123" }).failures).toEqual([]); + }); + + it("fails closed on head drift, required-check failure, and a hold label", () => { + const result = validatePullRequestSnapshot( + { + ...greenSnapshot, + headRefOid: "changed", + labels: [{ name: "hold" }], + statusCheckRollup: [{ name: "pr-required", conclusion: "FAILURE" }], + }, + { expectedHead: "abc123" }, + ); + expect(result.failures.join(" ")).toContain("does not match expected head"); + expect(result.failures.join(" ")).toContain("not settled successfully"); + expect(result.failures.join(" ")).toContain("blocking label"); + }); + + it("fails closed while GitHub mergeability or merge state is unsettled", () => { + const result = validatePullRequestSnapshot({ + ...greenSnapshot, + mergeable: "UNKNOWN", + mergeStateStatus: "BEHIND", + }); + expect(result.failures.join(" ")).toContain("expected MERGEABLE"); + expect(result.failures.join(" ")).toContain("expected CLEAN"); + }); + + it("fails closed when the required aggregate check is absent", () => { + const result = validatePullRequestSnapshot({ ...greenSnapshot, statusCheckRollup: [] }); + expect(result.failures).toContain("required check(s) missing: pr-required"); + }); + + it("accepts GitHub's workflow-qualified required-check context", () => { + const result = validatePullRequestSnapshot({ + ...greenSnapshot, + statusCheckRollup: [{ context: "CI / PR required", state: "SUCCESS" }], + }); + expect(result.failures).toEqual([]); + }); + + it("does not let a failed advisory check block a successful required aggregate", () => { + const result = validatePullRequestSnapshot({ + ...greenSnapshot, + statusCheckRollup: [ + { name: "PR required", conclusion: "SUCCESS" }, + { name: "Advisory UI", conclusion: "FAILURE" }, + ], + }); + expect(result.failures).toEqual([]); + expect(result.unsettledChecks).toEqual([]); + }); + + it("uses merged-state semantics for post-merge tree and health audits", () => { + expect( + validatePullRequestSnapshot({ ...greenSnapshot, state: "MERGED" }, { expectedHead: "abc123", postMerge: true }) + .failures, + ).toEqual([]); + }); +}); diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index a7d4040a6d..e18a94cf58 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -21,6 +21,8 @@ const hookSource = read("src/components/clinical-dashboard/use-hide-on-scroll.ts const headerSource = read("src/components/clinical-dashboard/master-search-header.tsx"); const shellSource = read("src/components/clinical-dashboard/global-search-shell.tsx"); const dashboardSource = read("src/components/ClinicalDashboard.tsx"); +const dashboardCoordinatorSource = read("src/components/clinical-dashboard/use-dashboard-chrome-coordinator.ts"); +const activeScrollOwnerSource = read("src/components/clinical-dashboard/use-active-scroll-owner.ts"); const dashboardResultComposerSlotSource = read( "src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx", ); @@ -42,7 +44,8 @@ describe("shared header hide/reveal wiring", () => { // GlobalSearchShell also passes pathname as resetKey so shared mode homes // do not inherit a collapsed top bar across routes. expect(shellSource).toContain("useScrollHideReporter(false, true, pathname)"); - expect(dashboardSource).toContain("useScrollHideReporter(false, true, searchMode)"); + expect(dashboardSource).toContain("useDashboardChromeCoordinator(searchMode)"); + expect(dashboardCoordinatorSource).toContain("useScrollHideReporter(false, true, resetKey)"); expect(hookSource).toContain("export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false"); }); @@ -53,7 +56,7 @@ describe("shared header hide/reveal wiring", () => { expect(shellSource).toContain( "useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainElement, inputRef)", ); - expect(dashboardSource).toContain( + expect(dashboardCoordinatorSource).toContain( "useDocumentScrollHideReporter(chromeScrollHide.reportScroll, mainScrollRoot, composerInputRef)", ); }); @@ -187,8 +190,22 @@ describe("shared header hide/reveal wiring", () => { expect(calculatorSearchSource).toContain("phone-footer-layer answer-footer-search-dock"); }); + it("exposes one stable diagnostic contract across each phone chrome owner", () => { + for (const source of [shellSource, dashboardSource, documentViewerSource]) { + expect(source).toContain("data-phone-scroll-owner"); + expect(source).toContain("data-phone-footer-owner"); + expect(source).toContain("data-phone-composer-reserve"); + expect(source).toContain("data-phone-chrome-transition"); + } + expect(dashboardCoordinatorSource).toContain("useActiveScrollOwner(mainScrollRoot, resetKey)"); + expect(activeScrollOwnerSource).toContain('export type ActiveScrollOwner = "pending" | "main" | "document"'); + expect(activeScrollOwnerSource).toContain('window.matchMedia("(display-mode: standalone)")'); + expect(activeScrollOwnerSource).toContain("new ResizeObserver(update)"); + expect(activeScrollOwnerSource).toContain("new MutationObserver(update)"); + }); + it("portals every page-owned phone footer beside the standalone scroller", () => { - expect(phoneFooterPortalSource).toContain("PhoneFooterLayerHostContext"); + expect(phoneFooterPortalSource).toContain("PhoneFooterLayerContext"); expect(phoneFooterPortalSource).toContain('className="phone-footer-layer-host contents"'); expect(phoneFooterPortalSource).toContain('data-testid="phone-footer-layer-host"'); expect(phoneFooterPortalSource).toMatch(/\{children\}[\s\S]*phone-footer-layer-host/); @@ -204,6 +221,16 @@ describe("shared header hide/reveal wiring", () => { expect(differentialPresentationSource).toContain('data-testid="differential-presentation-phone-footer"'); }); + it("shares the frame's authoritative scroll decision with the calculator footer", () => { + expect(phoneFooterPortalSource).toContain("export function usePhoneFooterLayerScrollHidden"); + expect(shellSource).toContain("scrollHidden={chromeScrollHide.hidden}"); + expect(dashboardSource).toContain("scrollHidden={chromeScrollHidden}"); + expect(calculatorSearchSource).toContain("const frameScrollHidden = usePhoneFooterLayerScrollHidden()"); + expect(calculatorSearchSource).toContain( + "const footerHidden = frameScrollHidden ?? (innerFooterHidden || documentFooterHidden)", + ); + }); + it("releases the phone top safe-area with hidden chrome while retaining the wide inset", () => { // A fixed phone safe-area sibling survives the 0fr header collapse as an // opaque band. It must share the hidden state, while sm+ sticky chrome keeps diff --git a/tests/installed-lock-parity.test.ts b/tests/installed-lock-parity.test.ts new file mode 100644 index 0000000000..28e4578355 --- /dev/null +++ b/tests/installed-lock-parity.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { installedLockParity } from "../scripts/check-installed-lock-parity.mjs"; + +const temporaryRoots: string[] = []; + +function fixture(lockedVersion: string, installedVersion?: string) { + const root = mkdtempSync(path.join(os.tmpdir(), "installed-lock-parity-")); + temporaryRoots.push(root); + writeFileSync( + path.join(root, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/next": { version: lockedVersion } } }), + ); + if (installedVersion) { + mkdirSync(path.join(root, "node_modules", "next"), { recursive: true }); + writeFileSync( + path.join(root, "node_modules", "next", "package.json"), + JSON.stringify({ version: installedVersion }), + ); + } + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("installedLockParity", () => { + it("accepts an installed package that exactly matches the lockfile", () => { + expect(installedLockParity(fixture("16.2.11", "16.2.11"), ["next"])).toEqual([ + expect.objectContaining({ packageName: "next", lockedVersion: "16.2.11", installedVersion: "16.2.11", ok: true }), + ]); + }); + + it("reports version drift before test output can be trusted", () => { + expect(installedLockParity(fixture("16.2.11", "16.2.10"), ["next"])[0]).toEqual( + expect.objectContaining({ ok: false, reason: "installed 16.2.10 does not match locked 16.2.11" }), + ); + }); + + it("fails closed when node_modules is absent", () => { + expect(installedLockParity(fixture("16.2.11"), ["next"])[0]).toEqual( + expect.objectContaining({ ok: false, installedVersion: null }), + ); + }); + + it("runs before local, UI, release, and CI test interpretation", () => { + const packageJson = JSON.parse(readFileSync(path.resolve("package.json"), "utf8")) as { + scripts: Record; + }; + const ci = readFileSync(path.resolve(".github/workflows/ci.yml"), "utf8"); + + for (const scriptName of ["verify:cheap:internal", "verify:ui", "verify:release"]) { + const script = packageJson.scripts[scriptName]; + const parityIndex = script.indexOf("check:installed-lock-parity"); + const firstTestIndex = Math.min( + ...["npm run lint", "npm run typecheck", "npm run test"] + .map((command) => script.indexOf(command)) + .filter((index) => index >= 0), + ); + expect(parityIndex, scriptName).toBeGreaterThan(-1); + expect(parityIndex, scriptName).toBeLessThan(firstTestIndex); + } + expect(readFileSync(path.resolve("scripts/verify-pr-local.mjs"), "utf8")).toContain( + '"check:runtime", "check:installed-lock-parity"', + ); + expect(ci).toContain("run: npm run check:installed-lock-parity"); + }); +}); diff --git a/tests/playwright-scroll.ts b/tests/playwright-scroll.ts index 3ff605d167..aa6ae72a57 100644 --- a/tests/playwright-scroll.ts +++ b/tests/playwright-scroll.ts @@ -12,6 +12,36 @@ export type PrimaryScrollGeometry = { viewportBottom: number; }; +export type DomGeometrySnapshot = { + selector: string; + count: number; + rect: null | { + top: number; + right: number; + bottom: number; + left: number; + width: number; + height: number; + }; + style: null | { + position: string; + overflowX: string; + overflowY: string; + paddingBottom: string; + opacity: string; + visibility: string; + transform: string; + bottom: string; + }; + data: Record; +}; + +export type PrimaryScrollDomSnapshot = { + scroll: PrimaryScrollGeometry; + viewport: { width: number; height: number }; + nodes: Record; +}; + /** * Phone dock clearance in CSS pixels for #main-content. * GlobalSearchShell applies the reserve on an inner pad (so it contributes to @@ -34,8 +64,11 @@ export async function readMobileComposerReservePx(main: Locator): Promise { - return page.evaluate(() => { +export async function readPrimaryScrollAndDomGeometry( + page: Page, + selectors: Record, +): Promise { + return page.evaluate((requestedSelectors) => { const main = document.getElementById("main-content"); const mainOverflowY = main ? window.getComputedStyle(main).overflowY : ""; const mainOwnsScroll = @@ -44,7 +77,7 @@ export async function readPrimaryScrollGeometry(page: Page): Promise { + const matches = document.querySelectorAll(selector); + const element = matches[0] ?? null; + const rect = element?.getBoundingClientRect() ?? null; + const style = element ? window.getComputedStyle(element) : null; + return [ + name, + { + selector, + count: matches.length, + rect: rect + ? { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + } + : null, + style: style + ? { + position: style.position, + overflowX: style.overflowX, + overflowY: style.overflowY, + paddingBottom: style.paddingBottom, + opacity: style.opacity, + visibility: style.visibility, + transform: style.transform, + bottom: style.bottom, + } + : null, + data: element + ? Object.fromEntries( + Object.entries(element.dataset).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : {}, + } satisfies DomGeometrySnapshot, + ]; + }), + ); + + return { scroll, viewport: { width: window.innerWidth, height: window.innerHeight }, nodes }; + }, selectors); +} + +export async function readPrimaryScrollGeometry(page: Page): Promise { + return (await readPrimaryScrollAndDomGeometry(page, {})).scroll; } /** Add deterministic runway inside app content without assuming who scrolls it. */ diff --git a/tests/playwright-settlement.ts b/tests/playwright-settlement.ts new file mode 100644 index 0000000000..a5af1e6e5b --- /dev/null +++ b/tests/playwright-settlement.ts @@ -0,0 +1,31 @@ +import { expect, type Locator } from "playwright/test"; + +/** + * Wait for a hydrating/portalling surface to converge to exactly one visible + * DOM owner. Hidden or outgoing duplicates are still duplicates: accepting + * `locator.first()` here would let a stale RSC tree hide a production defect. + */ +export async function expectSingleSettledOwner( + locator: Locator, + { message = "expected one settled DOM owner", timeout = 15_000 }: { message?: string; timeout?: number } = {}, +) { + await expect + .poll( + async () => { + const count = await locator.count(); + const visibility = await Promise.all( + Array.from({ length: count }, (_, index) => + locator + .nth(index) + .isVisible() + .catch(() => false), + ), + ); + return { count, visibleCount: visibility.filter(Boolean).length }; + }, + { message, timeout }, + ) + .toEqual({ count: 1, visibleCount: 1 }); + + return locator.first(); +} diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index e7234d0dd9..4a67c4e178 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -1,4 +1,6 @@ import { expect, test, type Locator, type Page } from "playwright/test"; +import { readPrimaryScrollAndDomGeometry } from "./playwright-scroll"; +import { expectSingleSettledOwner } from "./playwright-settlement"; /** * Phone scroll-geometry guardrail (the #964 regression class). @@ -170,6 +172,7 @@ function forceCompiledStandalonePhoneCss(page: Page): Promise { style.dataset.testid = "forced-standalone-phone-css"; style.textContent = `@layer components { ${standaloneRules.join("\n")} }`; document.head.append(style); + window.dispatchEvent(new Event("resize")); return standaloneRules.length; }); } @@ -347,32 +350,32 @@ test("phone browser results use document scrolling so Safari can minimize its br await expect(page.getByTestId("service-search-results")).toBeVisible({ timeout: 20_000 }); await addPhoneScrollRunway(page); - const initial = await page.evaluate(() => { - const main = document.getElementById("main-content"); - const shell = main?.closest(".phone-viewport-shell"); - const scrollingElement = document.scrollingElement ?? document.documentElement; - return { - shellPosition: shell ? getComputedStyle(shell).position : "missing", - shellOverflowY: shell ? getComputedStyle(shell).overflowY : "missing", - mainOverflowX: main ? getComputedStyle(main).overflowX : "missing", - mainOverflowY: main ? getComputedStyle(main).overflowY : "missing", - documentRunway: scrollingElement.scrollHeight - window.innerHeight, - windowScrollY: window.scrollY, - mainScrollTop: main?.scrollTop ?? -1, - }; + await expectSingleSettledOwner(page.locator("#main-content"), { message: "Services result scroll owner" }); + const initial = await readPrimaryScrollAndDomGeometry(page, { + main: "#main-content", + shell: ".phone-viewport-shell", + footer: "form.answer-footer-search-dock", }); - expect(initial.shellPosition, "the phone browser canvas must not use WebKit's fixed-root compositor path").not.toBe( - "fixed", - ); - expect(initial.shellOverflowY).toBe("visible"); - expect(initial.mainOverflowX, "x clipping must not silently turn the main surface back into a y scroller").toBe( - "clip", - ); - expect(initial.mainOverflowY).toBe("visible"); - expect(initial.documentRunway, "the document must own the Services result runway").toBeGreaterThan(500); - expect(initial.windowScrollY).toBe(0); - expect(initial.mainScrollTop).toBe(0); + expect( + initial.nodes.shell.style?.position, + "the phone browser canvas must not use WebKit's fixed-root compositor path", + ).not.toBe("fixed"); + expect(initial.nodes.shell.style?.overflowY).toBe("visible"); + expect( + initial.nodes.main.style?.overflowX, + "x clipping must not silently turn the main surface back into a y scroller", + ).toBe("clip"); + expect(initial.nodes.main.style?.overflowY).toBe("visible"); + expect(initial.scroll.owner).toBe("document"); + expect(initial.scroll.maxScrollTop, "the document must own the Services result runway").toBeGreaterThan(500); + expect(initial.scroll.scrollTop).toBe(0); + expect(initial.nodes.main.count).toBe(1); + expect(initial.nodes.footer.count).toBe(1); + expect(initial.nodes.main.data.phoneScrollOwner).toBe("document"); + expect(initial.nodes.main.data.phoneFooterOwner).toBe("shell"); + expect(initial.nodes.main.data.phoneComposerReserve).not.toBe("0rem"); + expect(initial.nodes.main.data.phoneChromeTransition).toBe("idle"); await page.evaluate(async () => { for (let step = 0; step < 32; step += 1) { @@ -411,6 +414,11 @@ test("document detail header and footer follow Safari document scrolling togethe await page.setViewportSize(phoneViewport); await gotoPhoneSurface(page, "/documents/11111111-1111-4111-8111-111111111111?page=1"); await expect(page.locator("form.document-viewer-composer")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("document-viewer-content")).toHaveAttribute("data-phone-scroll-owner", "document"); + await expect(page.getByTestId("document-viewer-content")).toHaveAttribute( + "data-phone-footer-owner", + "document-viewer", + ); await addPhoneScrollRunway(page); await dragScrollBy(page, 720, 24); @@ -435,6 +443,8 @@ test("compiled standalone PWA rules bind full-height footer chrome to the inner "compiled CSS must expose the standalone media rules", ).toBeGreaterThanOrEqual(4); await addPhoneScrollRunway(page); + await expect(page.locator("#main-content")).toHaveAttribute("data-phone-scroll-owner", "main"); + await expect(page.locator("#main-content")).toHaveAttribute("data-phone-footer-owner", "shell"); const initial = await page.evaluate(() => { const main = document.getElementById("main-content"); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 67ded7f9d8..8188304672 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4,6 +4,7 @@ import { stubZeroTouchPoints } from "./helpers/zero-touch"; import { appendPrimaryScrollSpacer, readMobileComposerReservePx, + readPrimaryScrollAndDomGeometry, readPrimaryScrollGeometry, scrollPrimarySurface, } from "./playwright-scroll"; @@ -138,6 +139,10 @@ async function fillVisibleQuestionInput(page: Page, value: string) { const questionInput = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible'); const submitAnswer = page.locator('[aria-label="Generate source-backed answer"]:visible'); + // Production HTML can be visible before React owns the controlled input. + // Filling during that gap is immediately overwritten by hydration and leaves + // the submit button disabled, so establish the live handler boundary first. + await waitForReactEventHandler(questionInput, "onChange"); await expect(async () => { // A production navigation can briefly overlap or replace the server-rendered // composer. Require one settled React owner before filling so the new client @@ -4027,11 +4032,18 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(generatedSummary).not.toContainText("**"); await expect(generatedSummary.locator("strong").filter({ hasText: "clozapine" })).toHaveCount(1); - const summaryBox = await generatedSummary.boundingBox(); - const previewBox = await page.getByTestId("pdf-preview").boundingBox(); - expect(summaryBox).not.toBeNull(); - expect(previewBox).not.toBeNull(); - expect(summaryBox!.y).toBeLessThan(previewBox!.y); + // The generated answer deliberately smooth-scrolls into view. Read both + // boxes in one browser evaluation so viewport motion cannot corrupt their + // relative order between independent Playwright round trips. + const answerGeometry = await readPrimaryScrollAndDomGeometry(page, { + summary: '[data-testid="generated-clinical-summary"]', + preview: '[data-testid="pdf-preview"]', + }); + expect(answerGeometry.nodes.summary.count).toBe(1); + expect(answerGeometry.nodes.preview.count).toBe(1); + expect(answerGeometry.nodes.summary.rect).not.toBeNull(); + expect(answerGeometry.nodes.preview.rect).not.toBeNull(); + expect(answerGeometry.nodes.summary.rect!.top).toBeLessThan(answerGeometry.nodes.preview.rect!.top); expect(answerRequests).toEqual([ { query: "How is clozapine monitored?", diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 3f5e2bf09d..b6309c786e 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -15,6 +15,7 @@ import { readPrimaryScrollGeometry, scrollPrimarySurface, } from "./playwright-scroll"; +import { expectSingleSettledOwner } from "./playwright-settlement"; const readySetupChecks = [ { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, @@ -654,9 +655,10 @@ test.describe("Clinical KB tools launcher", () => { // Production hydration can briefly overlap the outgoing server tree and // the settled client tree. Require the DOM to converge to one owner // before using strict locators; duplicate settled homes still fail. - await expect(homeSurface).toHaveCount(1, { timeout: 15_000 }); - await expect(homeSurface).toBeVisible(); - await expect(visibleGlobalSearchInput(page)).toHaveCount(1, { timeout: 15_000 }); + await expectSingleSettledOwner(homeSurface, { message: `${home.path} home owner` }); + await expectSingleSettledOwner(page.getByTestId("global-search-input"), { + message: `${home.path} composer owner`, + }); // The composer sits in the middle of the hero (in-flow) at phone width too, // not docked to the bottom edge: it renders inside the mode-home composer @@ -1796,8 +1798,11 @@ test.describe("Clinical KB tools launcher", () => { await expect(compareAction).toContainText("Compare selected"); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); - // Keep the composer focused while measuring end-of-list clearance so - // hide-on-scroll cannot collapse --mobile-composer-reserve mid-check. + // Begin with the visible composer reserve. Document scrolling deliberately + // blurs the focused composer, and the resulting chrome transition can + // change the document range after the first endpoint scroll. Re-issue the + // endpoint action while asserting so the position converges with the + // settled range instead of polling a stale scrollTop. await input.focus(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThan(180); @@ -1808,9 +1813,9 @@ test.describe("Clinical KB tools launcher", () => { await expect(async () => { await scrollPrimarySurface(page, "end"); const geometry = await readPrimaryScrollGeometry(page); + expect(geometry.owner).toBe("document"); expect(geometry.maxScrollTop - geometry.scrollTop).toBeLessThanOrEqual(1); }).toPass({ timeout: 15_000 }); - expect((await readPrimaryScrollGeometry(page)).owner).toBe("document"); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThan(180); const clearance = await page.evaluate(() => { diff --git a/tests/verify-phone-chrome.test.ts b/tests/verify-phone-chrome.test.ts new file mode 100644 index 0000000000..85acd0fe6f --- /dev/null +++ b/tests/verify-phone-chrome.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { phoneChromePlan } from "../scripts/phone-chrome-plan.mjs"; + +const ids = (files: string[], fullMode: "auto" | "always" | "never" = "auto") => + phoneChromePlan(files, { fullMode }).stages.map((stage) => stage.id); + +const stage = (files: string[], id: string) => phoneChromePlan(files).stages.find((candidate) => candidate.id === id); + +describe("phoneChromePlan", () => { + it("keeps documentation-only work out of browser suites", () => { + expect(ids(["docs/phone-chrome-physical-acceptance.md"])).toEqual(["docs-index", "docs-links"]); + }); + + it("runs focused browser/PWA ownership before the full UI suite for shared chrome", () => { + const selected = ids(["src/components/ClinicalDashboard.tsx"]); + expect(selected).toEqual(["lock-parity", "runtime", "contracts", "focused-browser", "full-ui"]); + expect(selected.indexOf("focused-browser")).toBeLessThan(selected.indexOf("full-ui")); + }); + + it("uses focused document ownership without escalating page-local work to the full suite", () => { + expect(ids(["src/components/DocumentViewer.tsx"])).toEqual([ + "lock-parity", + "runtime", + "contracts", + "focused-browser", + ]); + }); + + it("honours an explicit full-suite override while retaining focused stages first", () => { + const selected = ids(["tests/playwright-scroll.ts"], "always"); + expect(selected.at(-1)).toBe("full-ui"); + expect(selected).toContain("focused-browser"); + }); + + it("reports but does not silently re-enable an explicitly disabled recommended suite", () => { + const plan = phoneChromePlan(["src/components/clinical-dashboard/use-hide-on-scroll.ts"], { fullMode: "never" }); + expect(plan.fullRecommended).toBe(true); + expect(plan.fullSelected).toBe(false); + expect(plan.notes.join(" ")).toContain("recommended"); + }); + + it.each(["tests/ui-phone-scroll.spec.ts", "tests/ui-tools.spec.ts"])( + "runs every journey in changed browser spec %s", + (file) => { + const changedBrowser = stage([file], "changed-browser"); + expect(changedBrowser?.command.args).toContain(file); + expect(changedBrowser?.command.args).not.toContain("--grep"); + }, + ); +}); diff --git a/tests/verify-pr-local.test.ts b/tests/verify-pr-local.test.ts index 4c28dac0bb..a52c8017f1 100644 --- a/tests/verify-pr-local.test.ts +++ b/tests/verify-pr-local.test.ts @@ -28,6 +28,7 @@ describe("verify-pr-local CLI", () => { expect(output).toContain("Changed files: docs/frontend-architecture.md"); expect(output).toContain("PR-local verification plan (dry run)"); expect(output).toContain("- npm run check:runtime"); + expect(output).toContain("- npm run check:installed-lock-parity"); expect(output).toContain("- npm run format:changed"); expect(output).toContain("- build skipped"); expect(output).not.toContain("\n> npm run ");