diff --git a/.codex-screenshots/account-settings/desktop-account-settings.png b/.codex-screenshots/account-settings/desktop-account-settings.png deleted file mode 100644 index 5045dbcdc5..0000000000 Binary files a/.codex-screenshots/account-settings/desktop-account-settings.png and /dev/null differ diff --git a/.codex-screenshots/account-settings/mobile-account-settings.png b/.codex-screenshots/account-settings/mobile-account-settings.png deleted file mode 100644 index e769a5731d..0000000000 Binary files a/.codex-screenshots/account-settings/mobile-account-settings.png and /dev/null differ diff --git a/.codex-screenshots/rag-answer-responsive-desktop-full.png b/.codex-screenshots/rag-answer-responsive-desktop-full.png deleted file mode 100644 index 80e82c6993..0000000000 Binary files a/.codex-screenshots/rag-answer-responsive-desktop-full.png and /dev/null differ diff --git a/.codex-screenshots/rag-answer-responsive-desktop.png b/.codex-screenshots/rag-answer-responsive-desktop.png deleted file mode 100644 index 106454f7a6..0000000000 Binary files a/.codex-screenshots/rag-answer-responsive-desktop.png and /dev/null differ diff --git a/.codex-screenshots/rag-answer-responsive-mobile.png b/.codex-screenshots/rag-answer-responsive-mobile.png deleted file mode 100644 index 06e9b0b605..0000000000 Binary files a/.codex-screenshots/rag-answer-responsive-mobile.png and /dev/null differ diff --git a/.codex-screenshots/rag-answer-structure-mobile.png b/.codex-screenshots/rag-answer-structure-mobile.png deleted file mode 100644 index c61d016a84..0000000000 Binary files a/.codex-screenshots/rag-answer-structure-mobile.png and /dev/null differ diff --git a/.codex-screenshots/rag-answer-structure.png b/.codex-screenshots/rag-answer-structure.png deleted file mode 100644 index ad0b7ab8fb..0000000000 Binary files a/.codex-screenshots/rag-answer-structure.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-answer-interrupt.png b/.codex-screenshots/safety-critical-answer-interrupt.png deleted file mode 100644 index 6c9982d9a7..0000000000 Binary files a/.codex-screenshots/safety-critical-answer-interrupt.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-notes-triage.png b/.codex-screenshots/safety-critical-notes-triage.png deleted file mode 100644 index 6fcfce8e0a..0000000000 Binary files a/.codex-screenshots/safety-critical-notes-triage.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-redesign-clean.png b/.codex-screenshots/safety-critical-redesign-clean.png deleted file mode 100644 index 65ea04b79f..0000000000 Binary files a/.codex-screenshots/safety-critical-redesign-clean.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-redesign-desktop-full.png b/.codex-screenshots/safety-critical-redesign-desktop-full.png deleted file mode 100644 index 65ea04b79f..0000000000 Binary files a/.codex-screenshots/safety-critical-redesign-desktop-full.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-redesign-desktop.png b/.codex-screenshots/safety-critical-redesign-desktop.png deleted file mode 100644 index 24f25d56ce..0000000000 Binary files a/.codex-screenshots/safety-critical-redesign-desktop.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-redesign-mobile-clean.png b/.codex-screenshots/safety-critical-redesign-mobile-clean.png deleted file mode 100644 index 32011ced35..0000000000 Binary files a/.codex-screenshots/safety-critical-redesign-mobile-clean.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-redesign-mobile.png b/.codex-screenshots/safety-critical-redesign-mobile.png deleted file mode 100644 index 90b77c7315..0000000000 Binary files a/.codex-screenshots/safety-critical-redesign-mobile.png and /dev/null differ diff --git a/.codex-screenshots/safety-critical-source-verification.png b/.codex-screenshots/safety-critical-source-verification.png deleted file mode 100644 index d4d9f9e677..0000000000 Binary files a/.codex-screenshots/safety-critical-source-verification.png and /dev/null differ diff --git a/.codex-screenshots/safety-notes-triage-redesign-desktop-full.png b/.codex-screenshots/safety-notes-triage-redesign-desktop-full.png deleted file mode 100644 index 7e8a8e5b2a..0000000000 Binary files a/.codex-screenshots/safety-notes-triage-redesign-desktop-full.png and /dev/null differ diff --git a/.codex-screenshots/safety-notes-triage-redesign-mobile-full.png b/.codex-screenshots/safety-notes-triage-redesign-mobile-full.png deleted file mode 100644 index efea605049..0000000000 Binary files a/.codex-screenshots/safety-notes-triage-redesign-mobile-full.png and /dev/null differ diff --git a/.codex-screenshots/safety-notes-triage-redesign-mobile.png b/.codex-screenshots/safety-notes-triage-redesign-mobile.png deleted file mode 100644 index 6b8aee102f..0000000000 Binary files a/.codex-screenshots/safety-notes-triage-redesign-mobile.png and /dev/null differ diff --git a/.codex-screenshots/settings-complete-frontend-design-pass/desktop-1440-guide.png b/.codex-screenshots/settings-complete-frontend-design-pass/desktop-1440-guide.png deleted file mode 100644 index d1f6c7afbf..0000000000 Binary files a/.codex-screenshots/settings-complete-frontend-design-pass/desktop-1440-guide.png and /dev/null differ diff --git a/.codex-screenshots/settings-complete-frontend-design-pass/desktop-1440-settings.png b/.codex-screenshots/settings-complete-frontend-design-pass/desktop-1440-settings.png deleted file mode 100644 index d4f6d23833..0000000000 Binary files a/.codex-screenshots/settings-complete-frontend-design-pass/desktop-1440-settings.png and /dev/null differ diff --git a/.codex-screenshots/settings-complete-frontend-design-pass/phone-320-settings.png b/.codex-screenshots/settings-complete-frontend-design-pass/phone-320-settings.png deleted file mode 100644 index c9a7294808..0000000000 Binary files a/.codex-screenshots/settings-complete-frontend-design-pass/phone-320-settings.png and /dev/null differ diff --git a/.codex-screenshots/settings-complete-frontend-design-pass/phone-390-guide.png b/.codex-screenshots/settings-complete-frontend-design-pass/phone-390-guide.png deleted file mode 100644 index a198cb87c5..0000000000 Binary files a/.codex-screenshots/settings-complete-frontend-design-pass/phone-390-guide.png and /dev/null differ diff --git a/.codex-screenshots/settings-complete-frontend-design-pass/phone-390-settings.png b/.codex-screenshots/settings-complete-frontend-design-pass/phone-390-settings.png deleted file mode 100644 index 9c19e6dcef..0000000000 Binary files a/.codex-screenshots/settings-complete-frontend-design-pass/phone-390-settings.png and /dev/null differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ffa580cb9..4336120357 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,10 +68,14 @@ jobs: run: npm run build - name: Deployment boot smoke - run: npm run check:deployment-readiness + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') + # No placeholder fallbacks: when the repository secrets are missing the + # smoke must fail with its explicit missing-env error rather than + # "pass" against a server that never saw real production env. env: - SUPABASE_SERVICE_ROLE_KEY: placeholder-ci-service-role - OPENAI_API_KEY: placeholder-ci-openai + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: npm run check:deployment-readiness - name: Restore Chromium browser cache uses: actions/cache@v4 diff --git a/.gitignore b/.gitignore index 9e384b5f2f..d6b6f2e68f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,9 @@ supabase/.temp/ # typescript *.tsbuildinfo next-env.d.ts + +# agent/QA artifacts +.codex-screenshots/ +.qa-smoke/ +*.pid +tmp_output.txt diff --git a/COLOR_REDESIGN_PLAN.md b/COLOR_REDESIGN_PLAN.md index a063608bd7..e7c9c090f7 100644 --- a/COLOR_REDESIGN_PLAN.md +++ b/COLOR_REDESIGN_PLAN.md @@ -1,13 +1,16 @@ # Luxury Black-First Color Redesign Plan (Global UI Polish) ## 1) Intent + Apply a refined, premium dark-first visual system across the app with minimal risk: + - Keep semantics and component behavior unchanged. - Keep token architecture centralized in CSS variables. - Preserve accessibility and clinical readability. - Ensure light mode remains available but visually secondary. ## 2) Boundaries and Constraints + - No functional/logic edits. - No route/path rewrites, no new UI behavior. - No dependency/toolchain changes. @@ -15,6 +18,7 @@ Apply a refined, premium dark-first visual system across the app with minimal ri - All work is reversible and should be diff-reviewable in 3 small stages. ## 3) Success Definition (Done Criteria) + - Global theme reads as `obsidian/charcoal/luxury` (dark-first) while keeping high contrast. - `--surface`, `--text`, `--primary`, `--border`, focus and state tokens are consistently used. - Hard-coded production color usage reduced to near-zero in high-impact files. @@ -24,14 +28,17 @@ Apply a refined, premium dark-first visual system across the app with minimal ri ## 4) Stage Overview ### Stage 1 — Token Refresh + Theme Metadata (No behavior change) + **Goal:** finalize token system to luxury black-first in one controlled sweep. #### Files + - `C:\Dev\Apps\Database\src\app\globals.css` - `C:\Dev\Apps\Database\src\app\layout.tsx` - `C:\Dev\Apps\Database\src\lib\theme.ts` #### Edit checklist + 1. In `globals.css`, set foundation tokens for dark-first aesthetic: - Neutral ramps (`--background`, `--surface*`, `--text*`, `--border*`, `--ring*`, `--shadow*`, `--overlay-backdrop`, `--panel-gloss`) - Primary/accent tokens (reduced-brightness, high contrast on dark) @@ -42,15 +49,18 @@ Apply a refined, premium dark-first visual system across the app with minimal ri 5. In `theme.ts`, keep server snapshot/default aligned with dark-first philosophy. #### Exit checks + - `rg -n "(background|surface|text|border|primary|ring|shadow|overlay|panel-gloss)" src\app\globals.css` - Confirm no token names were removed/renamed (only value changes). --- ### Stage 2 — Token Migration of Production Color Exceptions + **Goal:** remove hardcoded/non-token surface/color usage from high-impact components. #### Files + - `C:\Dev\Apps\Database\src\components\ui\sheet.tsx` - `C:\Dev\Apps\Database\src\components\ui-primitives.tsx` - `C:\Dev\Apps\Database\src\components\DocumentViewer.tsx` @@ -58,12 +68,14 @@ Apply a refined, premium dark-first visual system across the app with minimal ri - `C:\Dev\Apps\Database\src\components\clinical-dashboard\medication-prescribing-workspace.tsx` #### Edit checklist + 1. Replace `bg-white`, `text-white`, `border-white`, direct slate utilities and hex fills with token-backed references. 2. Replace hardcoded status badges with semantic variants (`toneDanger`, `toneInfo`, `toneSuccess`, etc.) where available. 3. Keep spacing/layout/logic unchanged. 4. Confirm sheet, modal, viewer, dashboard, and medication workspace now visually map to token surfaces. #### Exit checks + - Token-first grep in target files: - `rg -n "bg-white|text-white|border-white|bg-slate|text-slate|border-slate|#([0-9a-fA-F]{3,8})" src\components\ui\sheet.tsx src\components\ui-primitives.tsx src\components\DocumentViewer.tsx src\components\ClinicalDashboard.tsx src\components\clinical-dashboard\medication-prescribing-workspace.tsx` - No behavior edits committed. @@ -71,13 +83,16 @@ Apply a refined, premium dark-first visual system across the app with minimal ri --- ### Stage 3 — Depth & Polish + QA Validation + **Goal:** finalize tactile depth and verify polished output across themes. #### Files (primarily) + - `C:\Dev\Apps\Database\src\app\globals.css` - Any residual files flagged in Stage 2 follow-up #### Edit checklist + 1. Fine-tune overlay/gloss/shadow stack: - reduce harsh white borders - convert glow to low-sheen, alpha-safe ink reflections @@ -86,6 +101,7 @@ Apply a refined, premium dark-first visual system across the app with minimal ri 3. Run final style consistency sweep for production files. #### Exit checks + - Manual visual QA after server boot: - `npm run ensure` - Browse sample flows: search, dashboard, document viewer, sheet/modal, medication prescribing workspace. @@ -95,6 +111,7 @@ Apply a refined, premium dark-first visual system across the app with minimal ri --- ## 5) Suggested Execution Order (Pragmatic) + 1. Stage 1 tokens + metadata 2. Stage 2 component hardcode replacement 3. Stage 3 polish + QA @@ -102,22 +119,26 @@ Apply a refined, premium dark-first visual system across the app with minimal ri This keeps risk low and allows rollback at each stage. ## 6) Rollback Strategy + - Stage-specific commits (or checkpoints): Stage1 / Stage2 / Stage3. - If any stage causes visual regression, revert only that stage’s files first. - Preserve `git status` checkpoints between stages. ## 7) Risk Register + - **Contrast drift (high):** especially in dense clinical content -> verify muted text/disabled states. - **Component inconsistency risk:** token-mapped components that rely on literal colors for hierarchy -> preserve local contrast hierarchy via token swaps only. - **Theme metadata mismatch:** server/client defaults mismatch -> validate first paint and browser local toggle. ## 8) Done Checklist (single source of truth) + - [ ] Stage 1 complete (tokens + metadata) - [ ] Stage 2 complete (component token migration) - [ ] Stage 3 complete (polish + QA) - [ ] Final review with screenshot evidence of main routes in dark mode ## 9) Current status (from this session) + - Stage 1 is partially started in `globals.css`. - `layout.tsx` and `theme.ts` still need completion before Stage 1 is final. - No files outside the plan scope should be edited until this document is approved. diff --git a/docs/redesign/clinical-white-aegean-master-implementation-plan.md b/docs/redesign/clinical-white-aegean-master-implementation-plan.md new file mode 100644 index 0000000000..87d9697964 --- /dev/null +++ b/docs/redesign/clinical-white-aegean-master-implementation-plan.md @@ -0,0 +1,443 @@ +# Clinical White / Aegean Graphite Master Implementation Plan + +## Final Review Verdict + +The permanent direction should be **Clinical White / Aegean Graphite**. + +The strongest version of this app is not cream, not green-led, and not a colourful dashboard. It should read as a precise clinical workbench: crisp white canvas, graphite command hierarchy, cool Aegean clinical identity, and restrained semantic status colours. + +The final colour system should communicate: + +- **Crispness:** true white primary canvas, not porcelain, cream, beige, or mint. +- **Professional weight:** graphite command controls instead of green primary buttons. +- **Clinical identity:** a cool blue-teal Aegean accent used with discipline. +- **Semantic clarity:** green means success only, amber means caution, red means critical, blue means information. +- **Dark-mode continuity:** black polish remains, but the light-mode system becomes cleaner and more premium. + +No further palette pivot is recommended. The final perfection is architectural: split command, clinical accent, and success roles so one colour is not doing multiple jobs. + +## Permanent Palette + +### Light Mode + +| Role | Token | Value | Use | +| --------------- | -------------------------- | --------- | ------------------------------------------------------------- | +| Canvas | `--background` | `#FFFFFF` | Main page background | +| Chrome | `--surface-chrome` | `#F7F8FA` | Sidebar, header bands, secondary app frame | +| Raised surface | `--surface-raised` | `#FCFCFD` | Cards, popovers, menus | +| Inset surface | `--surface-inset` | `#F1F4F6` | Inputs, inactive chips, recessed controls | +| Border | `--border` | `#E5E7EB` | Default lines | +| Strong border | `--border-strong` | `#CDD5DF` | Focused panels, active boundaries | +| Ink | `--foreground` | `#101418` | Body text | +| Heading | `--heading` | `#080B0F` | High-emphasis headings | +| Muted text | `--muted-foreground` | `#475467` | Secondary copy | +| Soft text | `--soft-foreground` | `#667085` | Captions and quiet metadata | +| Command | `--command` | `#111827` | Primary command buttons | +| Command hover | `--command-hover` | `#0B1220` | Primary command hover | +| Clinical accent | `--clinical-accent` | `#0B6F86` | Clinical identity, selected state, send action, evidence rail | +| Accent hover | `--clinical-accent-hover` | `#095D70` | Accent hover | +| Accent soft | `--clinical-accent-soft` | `#E7F6F8` | Tiny chips, icon wells, selected hints | +| Accent border | `--clinical-accent-border` | `#B9E4EA` | Subtle selected borders | +| Info | `--info` | `#2563EB` | Informational status | +| Success | `--success` | `#0F7A49` | Ready, connected, passed, complete | +| Warning | `--warning` | `#A15C07` | Caution and missing setup | +| Danger | `--destructive` | `#B42318` | Errors and destructive actions | + +### Dark Mode + +| Role | Token | Value | Use | +| --------------- | -------------------------- | --------- | ----------------------------------------------- | +| Canvas | `--background` | `#060708` | Main dark canvas | +| Chrome | `--surface-chrome` | `#0B0D0F` | Sidebar and header frame | +| Surface | `--surface` | `#101214` | Default panels | +| Raised surface | `--surface-raised` | `#171A1D` | Popovers and cards | +| Inset surface | `--surface-inset` | `#040506` | Inputs and composer body | +| Border | `--border` | `#23282C` | Default dark lines | +| Strong border | `--border-strong` | `#3B454B` | Active/focused dark lines | +| Ink | `--foreground` | `#F5F7F7` | Primary text | +| Muted text | `--muted-foreground` | `#A7B0AD` | Secondary text | +| Soft text | `--soft-foreground` | `#7F8987` | Captions | +| Command | `--command` | `#F5F7F7` | Dark primary command text/surfaces where needed | +| Clinical accent | `--clinical-accent` | `#4CCFD0` | Dark selected/send/evidence identity | +| Accent soft | `--clinical-accent-soft` | `#12383B` | Dark accent wash | +| Accent border | `--clinical-accent-border` | `#235B60` | Dark selected borders | +| Success | `--success` | `#7DE0A3` | Success status | +| Warning | `--warning` | `#F0C15A` | Warning status | +| Danger | `--destructive` | `#FF8D96` | Error/destructive status | + +## Final Design Rules + +1. **White is the product surface.** The page canvas is `#FFFFFF`. Use `#F7F8FA` only for rails, header bands, quiet chrome, and nested utility zones. +2. **Graphite owns command.** New chat, primary CTAs, high-emphasis neutral actions, and destructive-confirm-safe defaults should not be teal or green. +3. **Aegean owns clinical identity.** Selected mode, evidence/source affordances, focus rails, send action, and clinical scope indicators use `--clinical-accent`. +4. **Green is success only.** Do not use green for brand, active navigation, primary buttons, composer send, selected mode, empty-state hero marks, or evidence surfaces. +5. **Large soft-tinted panels are avoided.** Accent soft backgrounds belong on small chips, icon wells, rails, or tight state badges, not whole cards or page sections. +6. **Use lines before fills.** Premium selected states should prefer a 2px rail, slim underline, icon colour, or border over broad coloured tiles. +7. **Neutral cards stay neutral.** Repeated content cards should be white or raised off-white with nickel borders. The content, icon, or rail can carry the accent. +8. **Dark mode stays black-polished.** Do not copy light-mode glare, white inset highlights, or metallic gradients into dark mode. + +## Current Issues To Fix + +### 1. Light tokens are still too warm + +`src/app/globals.css` still sets the light mode around porcelain and teal. Values like `--background: #f7f7f4` keep the app reading warm and slightly cream instead of crisp white. + +### 2. Teal/green is overused + +The same colour family appears in sidebar brand elements, sidebar CTA, active tools, header mode controls, menu options, composer send, chips, empty state, and evidence states. This makes the system feel less premium because the accent has no hierarchy. + +### 3. `--primary` is overloaded + +`src/components/ui-primitives.tsx` uses `--primary` for both primary controls and evidence surfaces. That conflates "do this command" with "this is clinical evidence". This must be split before the palette can feel deliberate. + +### 4. Active states rely too much on filled tint + +Active navigation and tool states should use rails, borders, icon colour, and type weight. Large teal fills should be reduced. + +### 5. Composer must become the best light-mode object + +The composer is the most important object on the screen. In light mode it should be a white floating command capsule with nickel border, graphite text, restrained shadow, and Aegean send button. It should not glow green or look washed. + +## Implementation Sequence + +### Phase 0 - Safety And Baseline + +Before app edits: + +1. Inspect `git status --short` and avoid overwriting unrelated user work. +2. Keep all current screenshots and mockup artifacts untouched unless explicitly cleaning them later. +3. Read the current diff for these files before editing: + - `src/app/globals.css` + - `src/components/ui-primitives.tsx` + - `src/components/clinical-dashboard/ClinicalSidebar.tsx` + - `src/components/clinical-dashboard/master-search-header.tsx` + - `src/components/clinical-dashboard/answer-status.tsx` + - `src/components/ClinicalDashboard.tsx` +4. Because this is UI work, use `npm run ensure` before browser validation and only attach after `/api/local-project-id` confirms this project. + +### Phase 1 - Token Architecture + +Edit `src/app/globals.css`. + +Add the permanent role tokens in `:root` and `.dark`: + +```css +--surface-chrome: #f7f8fa; +--surface: #ffffff; +--surface-raised: #fcfcfd; +--surface-inset: #f1f4f6; +--border: #e5e7eb; +--border-strong: #cdd5df; + +--heading: #080b0f; +--foreground: #101418; +--muted-foreground: #475467; +--soft-foreground: #667085; + +--command: #111827; +--command-hover: #0b1220; + +--clinical-accent: #0b6f86; +--clinical-accent-hover: #095d70; +--clinical-accent-soft: #e7f6f8; +--clinical-accent-border: #b9e4ea; + +--info: #2563eb; +--success: #0f7a49; +--warning: #a15c07; +--destructive: #b42318; +``` + +Use compatibility aliases during migration: + +```css +--clinical-chat-teal: var(--clinical-accent); +--clinical-chat-teal-dark: var(--clinical-accent-hover); +--clinical-chat-teal-soft: var(--clinical-accent-soft); +``` + +Migration rule: + +- Do **not** remap `--primary` to `--command` until evidence/source surfaces have moved off `--primary`. +- First add `--command` and `--clinical-accent`. +- Then update callers. +- Then decide whether `--primary` should remain an alias for `--command` or be deprecated. + +### Phase 2 - Global Materials + +Edit global selectors in `src/app/globals.css`. + +Header: + +- `.edge-glass-header` uses a flatter white or chrome surface, not a warm translucent gradient. +- `.universal-header` becomes crisp white with a subtle nickel bottom border. +- `.universal-header-mode-button` becomes neutral white/chrome with small Aegean selected details. +- `.universal-header-icon-control` uses neutral hover by default. +- The header "New chat" hover should be graphite/neutral, not teal-filled. + +Composer: + +- `.answer-footer-search-pill` becomes a white floating capsule. +- Use `border: 1px solid var(--border-strong)` with soft graphite shadow. +- Remove green glow and heavy inset white highlight. +- Focus state uses a restrained Aegean ring or border. +- `.answer-footer-search-send` uses `--clinical-accent` and `--clinical-accent-hover`. +- `.answer-footer-search-action` stays neutral. +- `.answer-footer-search-chip` uses neutral surfaces with optional Aegean icons, not large tinted fills. + +Dark mode: + +- Keep black surfaces. +- Composer uses `#040506`/`#101214` material, not silver glare. +- Accent becomes `#4CCFD0`. +- Dark chips and controls stay low-glare with clear contrast. + +### Phase 3 - Primitive Role Split + +Edit `src/components/ui-primitives.tsx`. + +Split roles: + +- `primaryControl` uses `--command` and `--command-hover`. +- `evidenceSurface` uses `--clinical-accent`, `--clinical-accent-soft`, and `--clinical-accent-border`. +- Success, warning, danger, and info surfaces use semantic tokens only. + +Expected outcome: + +- Primary command buttons no longer look clinical-accent green. +- Evidence/source affordances no longer inherit command styling. +- Semantic success is visually distinct from selected/evidence. + +### Phase 4 - Sidebar Refinement + +Edit `src/components/clinical-dashboard/ClinicalSidebar.tsx` and associated CSS. + +Sidebar shell: + +- Use `--surface-chrome` for the rail. +- Use white/raised nested panels only where content needs elevation. +- Keep borders cool grey. + +Brand: + +- Brand text is graphite. +- Brand icon can use a small Aegean icon well. +- Avoid making the brand block look like a green status badge. + +Primary CTA: + +- `.clinical-sidebar-primary` uses graphite command. +- Hover deepens to `--command-hover`. +- Avoid teal/green primary CTA styling. + +Navigation/tool states: + +- Default items are neutral. +- Active item uses: + - white tile or very light chrome tile + - 2px Aegean left rail + - graphite label + - Aegean icon + - no broad teal fill +- Collapsed active buttons follow the same pattern with border/rail/icon treatment. + +Status utilities: + +- Ready/connected/complete states use `--success`. +- Tool availability or selected clinical modes use `--clinical-accent`. + +### Phase 5 - Header And Composer Wiring + +Edit `src/components/clinical-dashboard/master-search-header.tsx`. + +Header: + +- Selected mode icon uses `--clinical-accent`. +- Mode pill itself remains neutral. +- Active menu option should use a left rail, icon colour, or subtle border rather than a full accent wash. +- Header utility buttons stay neutral. +- Header New Chat uses command/graphite styling where it is a primary action. + +Composer: + +- Send button uses Aegean, not success green. +- Attachment, scope, tools, and mode actions stay neutral until selected. +- Selected chips use accent border or small icon colour, not filled teal blocks. +- Text and placeholder colours use `--foreground`, `--muted-foreground`, and `--soft-foreground`. + +Mobile: + +- Keep the composer visually light, but with enough border definition against white. +- Confirm footer safe-area spacing and no overlap with mobile navigation. + +### Phase 6 - Empty, Answer, Evidence, And Source States + +Edit as needed: + +- `src/components/clinical-dashboard/answer-status.tsx` +- `src/components/ClinicalDashboard.tsx` +- source/evidence cards or renderers that use `ui-primitives.tsx` + +Rules: + +- Empty state: white canvas, graphite heading, neutral starter cards, Aegean icons or rails only. +- Evidence/source cards: neutral white cards with Aegean left rail or small status chip. +- Completion/ready badges: success green. +- Missing source/setup: warning amber. +- Errors: destructive red. +- Informational notices: info blue. + +Avoid: + +- green empty-state hero icons unless the state is complete/success +- full accent-tinted answer cards +- mixed blue/green evidence semantics + +### Phase 7 - Component Audit + +Search and classify every remaining use of: + +```text +--clinical-chat-teal +--clinical-chat-teal-soft +--primary +--emerald +green- +teal- +bg-[var(--primary)] +text-[var(--primary)] +``` + +For each usage, assign one role: + +- command +- clinical accent +- success +- info +- warning +- danger +- neutral + +Then migrate to the correct token. + +Do not replace mechanically. Some teal usages are correct clinical accent usages; many green usages should become success only or neutral. + +### Phase 8 - Accessibility And Contrast + +Run contrast checks for: + +- graphite on white +- muted text on white +- soft text on white +- white on graphite command +- Aegean on white +- Aegean on accent soft +- success on white +- warning on white +- danger on white +- dark text on dark canvas +- dark accent on dark surfaces + +Required minimums: + +- Body text: 4.5:1 or better. +- Small UI labels: 4.5:1 or better unless purely decorative. +- Large text and icon-only affordances: 3:1 or better. +- Focus outlines: visible against adjacent background. + +Known selected values already pass spot checks, including: + +- `#101418` on white: strong body contrast. +- `#475467` on white: strong secondary contrast. +- `#667085` on white: acceptable soft text contrast. +- `#0B6F86` on white and `#E7F6F8`: acceptable accent contrast. +- `#0F7A49` on white: acceptable success contrast. + +### Phase 9 - Browser QA Matrix + +Use `npm run ensure` first, then verify the project identity through `/api/local-project-id`. + +Capture and review: + +- Light desktop dashboard. +- Light mobile dashboard. +- Dark desktop dashboard. +- Dark mobile dashboard. +- Sidebar expanded and collapsed. +- Sidebar mobile drawer. +- Header mode menu. +- Composer focused, empty, with chips, and with long prompt text. +- Empty state. +- Generated answer with evidence/source cards. +- Documents/search/results surfaces. +- Settings/account/theme utility area if reachable. + +Specific visual checks: + +- No cream cast on the main canvas. +- No large teal/green washed panels. +- Primary command action reads graphite. +- Send action reads Aegean. +- Green appears only for success/ready/connected/complete. +- Header and sidebar feel quieter than the main content. +- Composer is the most polished object on the page. +- Dark composer has no metallic glare. +- No text overlap or mobile horizontal scroll. + +### Phase 10 - Verification Commands + +Recommended sequence after edits: + +```powershell +npm run ensure +git diff --check +npm run verify:cheap +npm run verify:ui +``` + +If `verify:ui` is too broad or already running in another process, run the smallest relevant Playwright targets first, then widen: + +```powershell +npx playwright test tests/ui-smoke.spec.ts --project=chromium +npx playwright test tests/ui-accessibility.spec.ts --project=chromium +``` + +If CSS or Tailwind class generation changes are extensive, also run: + +```powershell +npm run build +``` + +Do not claim release readiness unless `npm run verify:release` is run successfully. + +## Acceptance Criteria + +The implementation is complete when: + +1. Light mode page canvas is true white. +2. Cream/porcelain background tokens are removed from primary app chrome. +3. Graphite is the primary command colour. +4. Aegean is the clinical accent and send/evidence identity colour. +5. Green is limited to success-only states. +6. `--primary` is no longer used for both command controls and evidence surfaces. +7. Sidebar active states use rail/border/icon treatment instead of broad teal fills. +8. Header is calmer and neutral, with only small clinical-accent details. +9. Composer is polished in light and dark mode. +10. Empty, answer, evidence, and source states use role-correct colours. +11. Desktop and mobile screenshots confirm the direction. +12. UI verification and contrast checks pass, or any residual failures are documented as pre-existing or unrelated. + +## Implementation Notes + +- Keep the first implementation pass token-led and component-scoped. +- Avoid introducing a parallel design system or new dependency. +- Prefer CSS variable role tokens over one-off hex values. +- Avoid broad layout changes unless a colour change exposes spacing or hierarchy problems. +- Preserve the current black-polish dark mode while aligning names and semantics. +- Update screenshots after the implementation so stale mockups do not mislead future review. + +## Final Recommendation + +Proceed with this palette and token architecture as the permanent direction. + +The app should become a crisp white clinical command surface with graphite controls and a disciplined Aegean accent. That combination is the cleanest, most premium, and most durable option for this product. diff --git a/docs/redesign/crisp-white-colour-system-plan.md b/docs/redesign/crisp-white-colour-system-plan.md new file mode 100644 index 0000000000..1ecadbc3d9 --- /dev/null +++ b/docs/redesign/crisp-white-colour-system-plan.md @@ -0,0 +1,214 @@ +# Crisp white colour system plan + +## Goal + +Replace the warm cream/porcelain light-mode direction with a cleaner, whiter, more polished clinical interface. The target is a premium white workspace: crisp like ChatGPT/Apple-style product surfaces, but still clinical, source-backed, and operational rather than decorative. + +The product is Clinical Guide: a source-backed clinical knowledge workspace for repeated question answering, document search, medication guidance, and safety review. The design job is to make the user trust the answer surface, understand source status quickly, and feel that the app is modern and precise. + +## Current design findings + +### Medium: the current light foundation still reads warm rather than crisp + +Evidence: + +- `src/app/globals.css:68` describes light mode as "porcelain workspace". +- `src/app/globals.css:94` sets `--background: #f7f7f4`. +- `src/app/globals.css:99-105` uses off-white raised/subtle/inset surfaces. + +Why it matters: + +- The user's newer direction is explicitly crisp white, not cream. +- A warm base can look softer and more editorial, but this app needs to feel precise, clean, and instrument-grade. + +Recommended fix: + +- Move the light canvas to true white. +- Use cool neutral rails and panels only where hierarchy is needed. +- Let depth come from hairline borders, low-alpha graphite shadows, and spacing, not a tinted page background. + +Verification: + +- A full-page light screenshot should read white first, not ivory or mint. + +### Medium: teal is still doing too much visual work + +Evidence: + +- `src/app/globals.css:113-122` maps primary and clinical chat aliases to teal. +- `src/components/clinical-dashboard/ClinicalSidebar.tsx:136` uses teal for the primary sidebar CTA. +- `src/components/clinical-dashboard/master-search-header.tsx:719-725` uses teal in the main answer-mode control. + +Why it matters: + +- Teal should communicate clinical evidence, source state, selected mode, and send intent. +- If the primary CTA, selected tile, source chip, icon tile, and composer all glow teal, the UI reads themed instead of premium. + +Recommended fix: + +- Use graphite for the strongest normal action. +- Use teal as an instrument accent: selected state rail, source confidence, send, and evidence-backed marks. +- Keep amber/red reserved for safety and setup, never for decoration. + +Verification: + +- In light mode, the dominant colours should be white, graphite, and cool neutral. Teal should appear as a deliberate signal. + +### Low: existing component hooks are good enough for a token-led implementation + +Evidence: + +- `src/app/globals.css:40-64` already exposes semantic Tailwind colour bridge tokens. +- `src/components/clinical-dashboard/master-search-header.tsx:800-884` centralises header actions and the answer footer composer shell. +- `src/components/clinical-dashboard/ClinicalSidebar.tsx:330` and `src/components/clinical-dashboard/ClinicalSidebar.tsx:422` centralise sidebar rails. + +Why it matters: + +- This should not need a component rewrite. +- The safest implementation path is token replacement plus a few targeted material overrides. + +Recommended fix: + +- Apply the new colour system first in `:root`. +- Then tune the sidebar, header, composer, empty state, answer cards, and source chips through existing class hooks. + +Verification: + +- The app should visually change without changing answer generation, routing, or data contracts. + +## Proposed design direction: Clinical White + +Clinical White is a clean white workspace with graphite command weight, nickel borders, and restrained clinical teal. It removes the cream page base and avoids broad mint washes. + +### Core tokens + +| Role | Token | Hex | Use | +| ---------------- | ------------- | --------- | ----------------------------------------------------------- | +| Canvas | `white-0` | `#FFFFFF` | Main app background | +| Rail | `white-rail` | `#F7F8FA` | Sidebar/header bands, subtle separated regions | +| Paper | `paper` | `#FFFFFF` | Cards, popovers, answer surfaces | +| Raised | `raised` | `#FCFCFD` | Floating controls and composer | +| Inset | `inset` | `#F1F3F5` | Search fields, subtle chip backgrounds | +| Border | `line` | `#E5E7EB` | Default hairline | +| Strong border | `line-strong` | `#D0D5DD` | Focused/selected outer lines | +| Ink | `ink` | `#101418` | Primary text | +| Graphite | `graphite` | `#111827` | Primary CTA, high-emphasis controls | +| Muted text | `muted` | `#475467` | Secondary text | +| Soft text | `soft` | `#667085` | Metadata, placeholders | +| Clinical teal | `teal` | `#0B7A75` | Evidence, selected rail, send | +| Soft teal | `teal-soft` | `#E6F7F5` | Low-emphasis evidence chips | +| Information blue | `blue` | `#2563EB` | Document/search information where teal would imply evidence | +| Safety amber | `amber` | `#A15C07` | Warnings and setup | +| Critical red | `red` | `#B42318` | Safety-critical states | + +### Colour rules + +- Page background is true white. +- Sidebars and header bands use `#F7F8FA`, not cream. +- Normal elevated surfaces stay white, with nickel borders. +- Graphite is the main command colour. +- Teal is a signal, not a theme wash. +- Blue is allowed for document/search metadata so teal stays clinical. +- Amber/red are semantic only. + +## Type and spacing + +- Keep the existing system font stack for implementation safety. +- Use compact, high-confidence headings: semibold, no negative letter spacing. +- Use 8px radius for most controls/cards, 12px only for larger sheets/composer. +- Reduce glow and blur in light mode. White UIs feel premium when details are exact, not when they are glossy. +- Use clear vertical rhythm: dense enough for a clinical workspace, with generous breathing room around the composer and answer surface. + +## Signature element + +Use a "clinical focus rail": + +- A 2px teal rail appears only on the selected mode, active source group, or evidence-backed answer card. +- The rail gives the design a memorable clinical instrument detail without tinting the whole page. +- It is more precise than a soft teal background and should replace broad mint active fills where possible. + +## Mockup structure + +Desktop: + +```text ++ Sidebar rail --+-- White header ------------------------------+ +| Graphite CTA | Mode control Utility buttons | +| Search +-----------------------------------------------+ +| Tools | | +| Focus rail | Answer workspace on white canvas | +| | Cards use nickel borders | +| Account | | ++----------------+------------- Floating white composer ---------+ +``` + +Mobile: + +```text ++--------------------------------+ +| Header: mode + new chat | +| White canvas | +| Answer cards with focus rail | +| Source chips | +| Floating white composer | ++--------------------------------+ +``` + +## Implementation plan + +1. Approve this mockup direction. +2. Replace the light `:root` palette in `src/app/globals.css`: + - `--background` to `#FFFFFF`. + - `--surface-subtle` to `#F7F8FA`. + - `--surface-inset` to `#F1F3F5`. + - borders to nickel greys. + - graphite as the primary command colour. +3. Re-map clinical chat aliases: + - `--clinical-chat-teal` remains teal. + - `--clinical-chat-teal-soft` becomes a very pale clinical tint. + - primary CTA should be graphite unless the control is specifically source/evidence/send. +4. Tune material selectors: + - `.edge-glass-header` + - `.universal-header` + - `.universal-header-mode-button` + - `.universal-header-icon-control` + - `.answer-footer-search-pill` + - `.answer-footer-search-action` + - `.answer-footer-search-chip` +5. Tune clinical hooks: + - `.clinical-sidebar-primary` + - `.clinical-sidebar-search-input` + - `.clinical-sidebar-secondary` + - `.clinical-sidebar-tool-tile` + - `.answer-empty-state` + - `.answer-empty-icon` + - `.answer-empty-action` +6. Add the clinical focus rail to selected/evidence cards where the component structure allows it without behavioural changes. +7. Verify dark mode after light changes so black polish does not inherit white material rules. + +## Verification plan + +- Light desktop screenshot: main dashboard. +- Light mobile screenshot: 390px wide. +- Dark desktop screenshot: regression check. +- Route smoke for the mockup. +- `git diff --check`. +- Targeted ESLint on changed mockup route. +- `npm run typecheck` if no existing repo-owned typecheck is already running. +- Contrast spot checks for text, muted text, teal chip, graphite CTA, amber warning, and red critical state. + +## Acceptance criteria + +- Light mode reads crisp white, not cream, beige, or mint. +- The app feels cleaner and more premium without becoming blank. +- Graphite carries command emphasis. +- Teal communicates clinical evidence/source/action only. +- Borders and shadows create enough hierarchy on a white canvas. +- Mobile stays clean with no horizontal overflow. +- Dark mode remains a paired black-polish system. + +## Self-critique and revision + +Initial idea: use a faint cool-grey app canvas behind white cards. + +Revision: the user asked for crisp white, so the final proposal makes the true app canvas `#FFFFFF` and uses cool grey only for rails, fields, and nested regions. This is a stricter, cleaner direction and a better match for the requested Apple/ChatGPT-inspired polish. diff --git a/docs/redesign/permanent-colour-direction.md b/docs/redesign/permanent-colour-direction.md new file mode 100644 index 0000000000..a63f23aba8 --- /dev/null +++ b/docs/redesign/permanent-colour-direction.md @@ -0,0 +1,192 @@ +# Permanent colour direction + +## Decision + +Adopt **Clinical White / Aegean Graphite** as the permanent colour direction. + +This is a crisp white, graphite-led interface with a cool blue-teal clinical accent. It replaces the warm cream/porcelain base and demotes green to success-only states. + +## Why this direction wins + +The app is a source-backed clinical workspace. It should feel precise, calm, premium, and operational. The interface should not feel like a generic medical brand, a mint healthcare template, or a soft editorial product. + +The strongest long-term direction is: + +- White for clarity. +- Graphite for command and product weight. +- Aegean blue-teal for clinical evidence and source confidence. +- Green only for completion/success. +- Amber and red only for safety states. + +This keeps the app clean and crisp while preserving a clinical identity. + +## Final palette + +### Light mode + +| Role | Token | Hex | Purpose | +| ---------------------- | -------------------------- | --------- | -------------------------------------------------------------------- | +| Canvas | `--background` | `#FFFFFF` | Main app canvas; no cream tint | +| Rail | `--surface-subtle` | `#F7F8FA` | Sidebar rail, header band, quiet nested areas | +| Surface | `--surface` | `#FFFFFF` | Cards, menus, answer panels | +| Raised surface | `--surface-raised` | `#FCFCFD` | Composer, floating controls | +| Inset surface | `--surface-inset` | `#F1F4F6` | Inputs, recessed chips, skeletons | +| Border | `--border` | `#E5E7EB` | Default hairline | +| Strong border | `--border-strong` | `#CDD5DF` | Active/focused boundaries | +| Text | `--text` | `#101418` | Body text | +| Heading | `--text-heading` | `#080B0F` | High-emphasis headings | +| Muted text | `--text-muted` | `#475467` | Secondary text | +| Soft text | `--text-soft` | `#667085` | Metadata and placeholders | +| Command | `--command` | `#111827` | Primary actions and high-emphasis command controls | +| Command hover | `--command-hover` | `#0B1220` | Hover/pressed command state | +| Clinical accent | `--clinical-accent` | `#0B6F86` | Evidence, selected mode, source confidence, send action | +| Clinical accent hover | `--clinical-accent-hover` | `#095D70` | Hover/pressed clinical action | +| Clinical accent soft | `--clinical-accent-soft` | `#E7F6F8` | Small evidence chips and icon tiles only | +| Clinical accent border | `--clinical-accent-border` | `#B9E4EA` | Selected/evidence borders | +| Info | `--info` | `#2563EB` | Document/search information where clinical confidence is not implied | +| Success | `--success` | `#0F7A49` | Ready, complete, connected, passed | +| Warning | `--warning` | `#A15C07` | Setup, caution, review required | +| Danger | `--danger` | `#B42318` | Critical/safety states | + +### Dark mode + +Keep the black-polish direction and pair it with a brighter cyan-blue accent. + +| Role | Token | Hex | +| -------------------- | ------------------------ | --------- | +| Canvas | `--background` | `#060708` | +| Surface | `--surface` | `#101214` | +| Raised surface | `--surface-raised` | `#171A1D` | +| Inset surface | `--surface-inset` | `#040506` | +| Text | `--text` | `#F5F7F7` | +| Muted text | `--text-muted` | `#A7B0AD` | +| Clinical accent | `--clinical-accent` | `#4CCFD0` | +| Clinical accent soft | `--clinical-accent-soft` | `#12383B` | +| Success | `--success` | `#7DE0A3` | +| Warning | `--warning` | `#F0C15A` | +| Danger | `--danger` | `#FF8D96` | + +## Role contract + +Do not map every important UI element to the same accent colour. + +```css +--command: #111827; +--command-hover: #0b1220; + +--clinical-accent: #0b6f86; +--clinical-accent-hover: #095d70; +--clinical-accent-soft: #e7f6f8; +--clinical-accent-border: #b9e4ea; + +--success: #0f7a49; +``` + +Mapping: + +- Primary command buttons: `--command` +- Sidebar `New chat`: `--command` +- Selected mode icon: `--clinical-accent` +- Send button: `--clinical-accent` +- Evidence/source state: `--clinical-accent` +- Small evidence chip/icon backgrounds: `--clinical-accent-soft` +- Ready/complete/connected/passed: `--success` +- Document/search metadata: `--info` +- Warnings: `--warning` +- Critical states: `--danger` + +## Element decisions + +### Sidebar + +- Background: rail `#F7F8FA`. +- Brand tile: small clinical accent icon on soft accent. +- `New chat`: graphite command button. +- Active item: white card with a 2px clinical accent rail. +- Tool icons: neutral by default, clinical accent only when active or clinically meaningful. + +### Header + +- Header material: white or near-white glass, low shadow, nickel border. +- Mode button: neutral white surface with a small clinical accent icon. +- Header action buttons: neutral/graphite, not green. +- Do not use broad accent backgrounds in the header. + +### Composer + +- White floating capsule. +- Nickel border. +- Graphite text. +- Soft graphite shadow. +- Send button uses clinical accent. +- Remove green glow and broad teal gradients. + +### Empty state + +- White canvas. +- Graphite heading. +- Neutral starter cards. +- Accent appears only in icons or a 2px focus rail. +- No washed green/mint cards. + +### Evidence and sources + +- Evidence-backed answer panels use a 2px clinical accent rail. +- Evidence chips can use clinical accent soft. +- Source readiness that means "success" uses success green, not clinical accent. + +### Status colours + +- Green is only success. +- Amber is only caution/setup/review. +- Red is only critical/safety. +- Blue is information/document/search. + +## Contrast check + +Spot checks against the final palette: + +| Pair | Ratio | +| ------------------------------ | ------- | +| Ink on white | 18.50:1 | +| Muted on white | 7.69:1 | +| Soft on white | 4.97:1 | +| White on graphite | 17.74:1 | +| Clinical accent on white | 5.78:1 | +| Clinical accent on soft accent | 5.21:1 | +| Success on white | 5.38:1 | +| Warning on white | 5.19:1 | +| Danger on white | 6.57:1 | +| Dark text on dark canvas | 18.75:1 | +| Dark accent on dark surface | 9.96:1 | + +## Rejected directions + +### Warm porcelain + +Rejected because it still reads cream/ivory and softens the app too much. + +### Green clinical + +Rejected because it feels generic healthcare and gives the same colour too many meanings. + +### Blue corporate SaaS + +Rejected because it loses the clinical/source-backed identity and feels less distinctive. + +### Pure monochrome + +Rejected because the app still needs a visible evidence/source signal. + +## Implementation order + +1. Add command and clinical accent tokens to `src/app/globals.css`. +2. Replace the light root palette with the final crisp white values. +3. Keep existing `--primary` temporarily mapped to command for backwards compatibility. +4. Move evidence/source styles from `--primary` to `--clinical-accent`. +5. Update sidebar/header/composer/empty-state hooks. +6. Verify light desktop, light mobile, dark desktop, and generated-answer states. + +## Final rule + +The app should read as **white, graphite, and precise blue-teal**. Green should only appear when the system is saying something has succeeded. diff --git a/docs/redesign/premium-colour-system-plan.md b/docs/redesign/premium-colour-system-plan.md new file mode 100644 index 0000000000..3abd071379 --- /dev/null +++ b/docs/redesign/premium-colour-system-plan.md @@ -0,0 +1,173 @@ +# Premium colour system plan + +## Goal + +Create a light mode that feels modern, premium, calm, and clinically trustworthy without becoming sterile, washed out, or over-teal. Preserve the existing black-polish direction for dark mode, but make both modes feel like one system. + +The product context is Clinical Guide: a source-backed clinical workspace used for repeated question answering, document review, medication guidance, and safety checks. The interface should feel like a polished professional tool, not a marketing page. + +## Current design findings + +### Medium: light mode is still too dependent on teal/mint + +Evidence: + +- `src/app/globals.css` defines the global light tokens and currently makes `--clinical-chat-teal-soft`, sidebar active states, empty-state cards, mode controls, and composer accents all read from the same teal family. +- `src/components/clinical-dashboard/ClinicalSidebar.tsx` and `src/components/clinical-dashboard/answer-status.tsx` now expose styling hooks, but the system still needs a more disciplined token contract. + +Why it matters: + +- When the background, active controls, icon tiles, and status chips all share the same green-teal family, light mode reads themed rather than premium. +- Teal should signal clinical action, evidence, and source state. It should not be the whole environment. + +Fix: + +- Move light mode to a neutral porcelain/graphite foundation. +- Keep teal as an instrument accent for selected mode, source state, send, and clinical evidence. +- Use amber/red only for setup/safety/escalation states. + +Verification: + +- Desktop and mobile screenshots should read as white/graphite first, teal second. + +### Medium: light surfaces need clearer material hierarchy + +Evidence: + +- The main hierarchy is shared between `globals.css`, `ui-primitives.tsx`, and dashboard-specific overrides in `master-search-header.tsx`, `ClinicalSidebar.tsx`, and `answer-status.tsx`. +- Existing cards, composer, and header use similar borders and shadows, so light mode can look flat or generically frosted. + +Why it matters: + +- Premium light UIs depend on precise separation: canvas, paper, floating glass, selected control, and warning state need distinct surface rules. + +Fix: + +- Define four light surfaces: canvas, paper, glass, elevated, and inset. +- Use low-alpha graphite shadows, not teal shadows, for normal elevation. +- Reserve colored shadows for focused/selected controls only. + +Verification: + +- Empty-state cards should sit above the canvas without looking boxed-in. +- The composer should feel like a floating white glass tool, not a bright pill or a heavy panel. + +### Low: dark mode can regress when base header/composer rules are changed + +Evidence: + +- `globals.css` has shared `.edge-glass-header`, `.universal-header`, and `.answer-footer-search-*` selectors plus `.dark` overrides. + +Why it matters: + +- Light-mode improvements can accidentally bleed into dark mode if base selectors are changed without matching `.dark` overrides. + +Fix: + +- Every material-level change to global base selectors must have a paired `.dark` review. +- Add a final dark screenshot after applying the light scheme. + +Verification: + +- Dark desktop screenshot: header remains black glass, composer remains black-polished, no white glare. + +## Proposed colour system + +### Light mode: Clinical Porcelain + +- Canvas: `#F7F7F4` +- Paper: `#FFFFFF` +- Raised paper: `#FCFCFA` +- Graphite ink: `#111714` +- Muted graphite: `#53605A` +- Hairline border: `#DFDFD8` +- Instrument teal: `#0F766E` +- Soft teal: `#E6F2EF` +- Safety amber: `#8F5408` +- Critical red: `#B23A48` +- Optional cool info: `#2F6F91` + +Design role: + +- Graphite primary CTA and text give the Apple/ChatGPT-style premium feel. +- Teal is used for clinical/source affordances only. +- Warm porcelain prevents the app from reading as hospital-mint or generic SaaS blue-grey. + +### Dark mode: Obsidian Glass + +- Canvas: `#070808` +- Surface: `#101214` +- Raised surface: `#171A1D` +- Glass edge: `rgba(255,255,255,0.08)` +- Text: `#F4F7F6` +- Muted text: `#A7B0AD` +- Instrument teal: `#4CCFD0` +- Soft teal: `#12383B` +- Safety amber: `#F0C15A` +- Critical red: `#FF8D96` + +Design role: + +- Dark remains the black-polished system. +- The same semantic accents are preserved, but lifted for contrast. + +## Type and spacing direction + +- Keep the existing app font stack for implementation safety. +- Use weight, size, and spacing to create polish rather than introducing a new font dependency. +- Main answer/empty-state headings should use compact, high-confidence typography: semibold, no negative tracking. +- Utility labels stay small, uppercase only where they label system groups such as `Sources`, `Safety`, or `Mode`. + +## Signature element + +Use a “frosted clinical tray” for the composer and header controls: + +- White/dark glass surface. +- Single hairline border. +- Soft graphite elevation. +- Teal send/action affordance. +- No decorative orbs or broad gradients. + +This is the memorable material treatment. Everything else should be quiet. + +## Implementation sequence + +1. Approve a mockup direction. +2. Replace light `:root` tokens in `src/app/globals.css` with the Clinical Porcelain palette. +3. Pair every base material selector with `.dark` review/overrides: + - `.edge-glass-header` + - `.universal-header` + - `.universal-header-mode-button` + - `.universal-header-icon-control` + - `.answer-footer-search-pill` + - `.answer-footer-search-action` + - `.answer-footer-search-chip` +4. Keep sidebar and empty-state hooks: + - `.clinical-sidebar-primary` + - `.clinical-sidebar-search-input` + - `.clinical-sidebar-secondary` + - `.clinical-sidebar-tool-tile` + - `.answer-empty-state` + - `.answer-empty-icon` + - `.answer-empty-action` +5. Apply the palette in one pass to dashboard surfaces and source/document panels. +6. Run screenshots: + - Light desktop home + - Light mobile home + - Dark desktop home + - Documents page light + - Answer generated light +7. Run checks: + - `git diff --check` + - `npm run check:runtime` + - `npm run typecheck` + - Focused Playwright screenshot smoke if install state is healthy. + +## Acceptance criteria + +- Light mode reads neutral premium first, clinical teal second. +- No large green/mint wash over the canvas. +- Header, sidebar, empty state, and composer feel like the same material system. +- Warning/setup states are readable and visually separate from teal action states. +- Dark mode does not inherit light glass or white glare. +- No horizontal overflow on 390px mobile and 1280px desktop. diff --git a/eslint.config.mjs b/eslint.config.mjs index cea1650b8c..db058f597b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,10 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "coverage/**", + "output/**", + ".codex-screenshots/**", + ".qa-smoke/**", + ".tmp-playwright-*/**", ".claude/**", "playwright-report/**", "test-results/**", diff --git a/next.config.ts b/next.config.ts index 4f15443cc1..f4ddb45c04 100644 --- a/next.config.ts +++ b/next.config.ts @@ -40,6 +40,9 @@ const securityHeaders = [ const nextConfig: NextConfig = { devIndicators: false, + experimental: { + cpus: 1, + }, poweredByHeader: false, turbopack: { root: projectRoot, diff --git a/package.json b/package.json index c55c1ad54f..b8a99eaecb 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,17 @@ "dev": "node scripts/dev-free-port.mjs", "preinstall": "node scripts/check-node-engine.cjs", "ensure": "node scripts/ensure-local-server.mjs", - "build": "next build", + "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack", "start": "node scripts/dev-free-port.mjs start", - "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "test:e2e": "node ./node_modules/playwright/cli.js test", - "test:e2e:all": "node ./node_modules/playwright/cli.js test", - "test:e2e:accessibility": "node ./node_modules/playwright/cli.js test tests/ui-accessibility.spec.ts --project=chromium", - "test:e2e:chromium": "node ./node_modules/playwright/cli.js test --project=chromium", - "test:e2e:visual": "node ./node_modules/playwright/cli.js test --config=playwright.visual.config.ts", + "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --no-error-on-unmatched-pattern", + "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", + "test": "node scripts/run-vitest.mjs run --reporter=dot", + "test:coverage": "node scripts/run-vitest.mjs run --coverage", + "test:e2e": "node scripts/run-playwright.mjs", + "test:e2e:all": "node scripts/run-playwright.mjs", + "test:e2e:accessibility": "node scripts/run-playwright.mjs tests/ui-accessibility.spec.ts --project=chromium", + "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium", + "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "verify:cheap": "npm run check:runtime && npm run lint && npm run typecheck && npm run test", "verify:ui": "npm run check:runtime && npm run test:e2e:chromium", "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 eval:quality:release", @@ -52,12 +52,12 @@ "reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts", "supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts", "promote:query-misses": "tsx scripts/promote-query-misses.ts", - "eval:rag": "tsx scripts/eval-rag.ts", - "eval:quality": "tsx scripts/eval-quality.ts", + "eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts", + "eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts", "eval:quality:release": "npm run eval:quality -- --fail-on-threshold --source-metadata-debt docs/release-source-metadata-debt-2026-06-30.json", - "eval:retrieval": "tsx scripts/eval-retrieval.ts", - "eval:retrieval:quality": "tsx scripts/eval-retrieval.ts --mode quality", - "eval:retrieval:latency": "tsx scripts/eval-retrieval.ts --mode latency --case-timeout-ms 25000 --p90-ms 20000", + "eval:retrieval": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts", + "eval:retrieval:quality": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts --mode quality", + "eval:retrieval:latency": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts --mode latency --case-timeout-ms 25000 --p90-ms 20000", "eval:search": "tsx scripts/eval-search.ts", "eval:search:api": "tsx scripts/eval-search-api.ts", "retrieval:health": "tsx scripts/retrieval-health.ts", diff --git a/playwright.config.ts b/playwright.config.ts index 1d3a04371c..73b3ee1e4b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ testDir: "./tests", testMatch: /.*ui-(smoke|stress|accessibility|tools)\.spec\.ts/, timeout: 60_000, + retries: process.env.CI ? 1 : 0, expect: { timeout: 10_000, }, diff --git a/public/mockups/crisp-white-colour-system-desktop.png b/public/mockups/crisp-white-colour-system-desktop.png new file mode 100644 index 0000000000..5c256d8b1a Binary files /dev/null and b/public/mockups/crisp-white-colour-system-desktop.png differ diff --git a/public/mockups/crisp-white-colour-system-mobile.png b/public/mockups/crisp-white-colour-system-mobile.png new file mode 100644 index 0000000000..d4d3e0aee5 Binary files /dev/null and b/public/mockups/crisp-white-colour-system-mobile.png differ diff --git a/public/mockups/premium-colour-system-mobile.png b/public/mockups/premium-colour-system-mobile.png new file mode 100644 index 0000000000..19143a5f89 Binary files /dev/null and b/public/mockups/premium-colour-system-mobile.png differ diff --git a/scripts/backfill-source-metadata.ts b/scripts/backfill-source-metadata.ts index 7f2543e976..14773887a4 100644 --- a/scripts/backfill-source-metadata.ts +++ b/scripts/backfill-source-metadata.ts @@ -71,9 +71,6 @@ function titleWithoutExtension(fileName: string) { } function publisherCodeFor(document: DocumentRow, text = "") { - if (/\bBM[J)]\s+Best\s+Practice\b/i.test(text) || /\bStraight\s+to\s+the\s+point\s+of\s+care\b/i.test(text)) { - return "BMJ"; - } const haystack = `${document.file_name} ${document.title} ${document.source_path ?? ""}`; const parentheticalCodes = [...haystack.matchAll(/\(([A-Z]{2,8})\)/g)].map((match) => match[1]); for (const code of parentheticalCodes) { @@ -82,12 +79,16 @@ function publisherCodeFor(document: DocumentRow, text = "") { for (const code of Object.keys(publisherByCode).sort((a, b) => b.length - a.length)) { if (new RegExp(`(?:^|[\\\\/\\s])${code}(?:[\\\\/\\s]|$)`, "i").test(haystack)) return code; } + if (/\bBM[J)]\s+Best\s+Practice\b/i.test(text) || /\bStraight\s+to\s+the\s+point\s+of\s+care\b/i.test(text)) { + return "BMJ"; + } return null; } function sourceTypeFor(document: DocumentRow, text: string) { const haystack = `${document.title} ${document.file_name} ${text.slice(0, 1500)}`.toLowerCase(); - if (haystack.includes("standard operational procedure") || /\bsop\b/.test(haystack)) return "standard_operating_procedure"; + if (haystack.includes("standard operational procedure") || /\bsop\b/.test(haystack)) + return "standard_operating_procedure"; if (haystack.includes("policy and procedure")) return "policy_procedure"; if (/\bprocedure\b/.test(haystack)) return "procedure"; if (/\bpolicy\b/.test(haystack)) return "policy"; @@ -200,14 +201,41 @@ function parseClinicalDate(raw: string, options: { endOfMonth?: boolean } = {}) function firstMatchDate(text: string, labels: string[], endOfMonth: boolean) { const datePattern = "([0-3]?\\d[/-][01]?\\d[/-]20\\d{2}|[01]?\\d[/-]20\\d{2}|(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\s+[0-3]?\\d,?\\s+20\\d{2}|(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\s+20\\d{2}|[0-3]?\\d\\s+(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\s+20\\d{2})"; + const labelSeparator = "[\\s:;,*()\\-]*"; for (const label of labels) { - const pattern = new RegExp(`${label}\\s*:?\\s*${datePattern}`, "i"); + const pattern = new RegExp(`${label}${labelSeparator}${datePattern}`, "i"); const match = text.match(pattern); if (match) { const parsed = parseClinicalDate(match[1], { endOfMonth }); if (parsed) return { date: parsed, raw: normalizeWhitespace(match[0]) }; } - if (/^(?:Review Due|Revision Due)$/i.test(label)) { + + if (/^(?:Review Due|Revision Due|Revision Date|Next Review|Review Date)$/i.test(label)) { + const monthThenYearPattern = new RegExp( + `${label}[\\s\\S]{0,100}?\\b(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\b[\\s\\S]{0,50}?\\b(20\\d{2})\\b`, + "i", + ); + const monthThenYearMatch = text.match(monthThenYearPattern); + if (monthThenYearMatch) { + const parsed = parseClinicalDate(`${monthThenYearMatch[1]} ${monthThenYearMatch[2]}`, { endOfMonth }); + if (parsed) return { date: parsed, raw: normalizeWhitespace(monthThenYearMatch[0]) }; + } + } + + if ( + /^(?:Review Due|Revision Due|Revision Date|Next Review|Review Date|Last Reviewed|Authorisation date|Published date|First Issued|Approved by|Endorsed by|Authorised by)$/i.test( + label, + ) + ) { + const nearLabelPattern = new RegExp(`${label}[\\s\\S]{0,120}?${datePattern}`, "i"); + const nearLabelMatch = text.match(nearLabelPattern); + if (nearLabelMatch) { + const parsed = parseClinicalDate(nearLabelMatch[1], { endOfMonth }); + if (parsed) return { date: parsed, raw: normalizeWhitespace(nearLabelMatch[0]) }; + } + } + + if (/^(?:Review Due|Revision Due|Revision Date|Review Date)$/i.test(label)) { const labelIndex = text.toLowerCase().indexOf(label.toLowerCase()); if (labelIndex >= 0) { const window = text.slice(labelIndex, labelIndex + 320); @@ -235,8 +263,30 @@ function firstMatchDate(text: string, labels: string[], endOfMonth: boolean) { return null; } +function standaloneReviewDate(text: string) { + const datePattern = + "([0-3]?\\d[/-][01]?\\d[/-]20\\d{2}|[01]?\\d[/-]20\\d{2}|(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\s+[0-3]?\\d,?\\s+20\\d{2}|(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\s+20\\d{2}|[0-3]?\\d\\s+(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\s+20\\d{2})"; + const reviewedThenReview = text.match( + new RegExp(`\\bReviewed\\s+${datePattern}[\\s\\S]{0,100}?\\bReview\\s+${datePattern}`, "i"), + ); + if (reviewedThenReview?.[2]) { + const parsed = parseClinicalDate(reviewedThenReview[2], { endOfMonth: true }); + if (parsed) return { date: parsed, raw: normalizeWhitespace(reviewedThenReview[0]) }; + } + + const review = text.match(new RegExp(`\\bReview\\s*[:,]?\\s*${datePattern}`, "i")); + if (review?.[1]) { + const parsed = parseClinicalDate(review[1], { endOfMonth: true }); + if (parsed) return { date: parsed, raw: normalizeWhitespace(review[0]) }; + } + + return null; +} + function extractDates(text: string) { - const review = firstMatchDate(text, ["Review Due", "Revision Due", "Review Date", "Next Review"], true); + const review = + firstMatchDate(text, ["Review Due", "Revision Due", "Revision Date", "Review Date", "Next Review"], true) ?? + standaloneReviewDate(text); const publication = firstMatchDate( text, @@ -245,6 +295,9 @@ function extractDates(text: string) { "Published date", "First Issued", "Date Compiled", + "Date of Issue", + "Date First Issued", + "Issue Date", "Last updated", "Last Reviewed", "Authorised by", @@ -253,8 +306,7 @@ function extractDates(text: string) { "Endorsed", ], false, - ) ?? - null; + ) ?? null; const lastUpdated = firstMatchDate(text, ["Last updated", "Updated"], false); const reviewCycle = /\b(?:reviewed|evaluated)[^.]{0,120}\bat least every three\s*(?:\(\s*3\s*\)|3)?\s*years?\b/i.test(text) || @@ -322,17 +374,17 @@ function clinicalValidationEvidenceFor(args: { { type: "committee_endorsement", pattern: - /\b(?:committee\/consumer\s+endorsed\s+by|endorsed\s+by|endorsed)\b[\s\S]{0,260}\b(?:committee|clinical|governance|safety|quality|risk|drug|therapeutics|executive|service\s+director|director|co-?director|nurse\s+director|medical\s+director|commissioning|assurance|group|DONM|DCS|CPC|HoLAA)\b/i, + /\b(?:committee\/consumer\s+endorsed\s+by|endorsed\s+by|endorsed)\b[\s\S]{0,260}\b(?:committee|clinical|governance|safety|quality|risk|drug|therapeutics|executive|service\s+director|director|co-?director|nurse\s+director|medical\s+director|head\s+of\s+department|HOD|NUM|CNC|CN|consultant|physiotherapy|pharmacy|haematology|respiratory|transfusion|commissioning|assurance|group|DONM|DCS|CPC|HoLAA)\b/i, }, { type: "committee_approval", pattern: - /\b(?:approved\s+by|approval\s+by|approved)\b[\s\S]{0,260}\b(?:committee|clinical|governance|safety|quality|risk|drug|therapeutics|executive|service\s+director|director|co-?director|nurse\s+director|medical\s+director|commissioning|assurance|group|DONM|DCS|CPC|HoLAA)\b/i, + /\b(?:approved\s+by|approval\s+by|approved)\b[\s\S]{0,260}\b(?:committee|clinical|governance|safety|quality|risk|drug|therapeutics|executive|service\s+director|director|co-?director|nurse\s+director|medical\s+director|head\s+of\s+department|HOD|NUM|CNC|CN|consultant|physiotherapy|pharmacy|haematology|respiratory|transfusion|commissioning|assurance|group|DONM|DCS|CPC|HoLAA)\b/i, }, { type: "authorisation", pattern: - /\b(?:authorisation|authorised\s+by|authorized\s+by|executive\s+sponsor)\b[\s\S]{0,300}\b(?:committee|clinical|governance|safety|quality|risk|drug|therapeutics|executive|service\s+director|director|co-?director|nurse\s+director|medical\s+director|sponsor|commissioning|assurance|group|DONM|DCS|CPC|HoLAA)\b/i, + /\b(?:authorisation|authorised\s+by|authorized\s+by|executive\s+sponsor)\b[\s\S]{0,300}\b(?:committee|clinical|governance|safety|quality|risk|drug|therapeutics|executive|service\s+director|director|co-?director|nurse\s+director|medical\s+director|head\s+of\s+department|HOD|NUM|CNC|CN|consultant|physiotherapy|pharmacy|haematology|respiratory|transfusion|sponsor|commissioning|assurance|group|DONM|DCS|CPC|HoLAA)\b/i, }, { type: "policy_sponsor", @@ -342,7 +394,7 @@ function clinicalValidationEvidenceFor(args: { { type: "document_control_owner", pattern: - /\b(?:document\s+owner|policy\s+owner|procedure\s+owner)\b[\s\S]{0,180}\b(?:clinical|medical|nursing|pharmacy|mental\s+health|service|director|committee)\b/i, + /\b(?:document\s+owner|policy\s+owner|procedure\s+owner)\b[\s\S]{0,180}\b(?:clinical|medical|nursing|pharmacy|mental\s+health|service|director|committee|head\s+of\s+department|HOD|NUM|CNC|CN|consultant|physiotherapy|haematology|respiratory|transfusion)\b/i, }, ]; @@ -371,7 +423,11 @@ function extractionQualityFor(quality: QualityRow | undefined, existing: string) const score = typeof quality?.quality_score === "number" ? quality.quality_score : null; const issues = Array.isArray(quality?.issues) ? quality.issues.map(String).join(" ") : String(quality?.issues ?? ""); if (qualityValue === "poor" || (score !== null && score < 0.52)) return "poor"; - if (qualityValue === "good" && (score === null || score >= 0.72) && !/\b(?:failed|ocr|missing text)\b/i.test(issues)) { + if ( + qualityValue === "good" && + (score === null || score >= 0.72) && + !/\b(?:failed|ocr|missing text)\b/i.test(issues) + ) { return "good"; } if (qualityValue === "partial" || qualityValue === "good" || (score !== null && score >= 0.52)) return "partial"; @@ -410,7 +466,8 @@ function deriveMetadata(document: DocumentRow, text: string, quality: QualityRow const changedKeys: string[] = []; const publisherCode = publisherCodeFor(document, text); const publisher = publisherCode ? publisherByCode[publisherCode] : null; - const dates = extractDates(text); + const extractedDates = extractDates(text); + const dates = publisherCode === "BMJ" ? { ...extractedDates, review: null } : extractedDates; const documentStatus = documentStatusFor(dates, publisherCode); const existingValidation = String(metadata.clinical_validation_status ?? "unverified"); const clinicalValidation = clinicalValidationEvidenceFor({ @@ -451,8 +508,7 @@ function deriveMetadata(document: DocumentRow, text: string, quality: QualityRow publisher: publisherCode ? "filename/source_path code" : "not inferred", document_status: dates.review?.raw ?? dates.lastUpdated?.raw ?? "not inferred", publication_date: dates.publication?.raw ?? "not inferred", - clinical_validation_status: - clinicalValidation.basis, + clinical_validation_status: clinicalValidation.basis, extraction_quality: quality ? `document_index_quality:${quality.extraction_quality ?? "unknown"} score:${quality.quality_score ?? "unknown"}` : "existing metadata", diff --git a/scripts/check-document-label-coverage.ts b/scripts/check-document-label-coverage.ts index a44bccd7ef..c5d49ab082 100644 --- a/scripts/check-document-label-coverage.ts +++ b/scripts/check-document-label-coverage.ts @@ -123,7 +123,7 @@ async function loadAllowlist(path: string | undefined) { return new Set(parseAllowlistValue(raw)); } -async function fetchAll( +async function fetchAll( supabase: SupabaseAdmin, table: "documents" | "document_labels", select: string, @@ -146,8 +146,7 @@ async function fetchAll( const nextRows = data ?? []; rows.push(...nextRows); if (nextRows.length < pageSize) break; - const lastRow = nextRows[nextRows.length - 1] as { id?: string }; - const lastId = lastRow?.id; + const lastId = nextRows[nextRows.length - 1]?.id; if (!lastId || lastId === cursor) break; cursor = lastId; } @@ -163,6 +162,8 @@ function countByLabelType(labels: LabelRow[]) { return Object.fromEntries([...counts.entries()].sort()); } +const smartV2LabelTypes = new Set(["clinical_action", "care_phase", "document_intent", "content_feature"]); + async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { @@ -186,8 +187,11 @@ async function main() { const documentTypeDocumentIds = new Set( labels.filter((label) => label.label_type === "document_type").map((label) => label.document_id), ); + const smartV2Labels = labels.filter((label) => smartV2LabelTypes.has(label.label_type)); + const smartV2DocumentIds = new Set(smartV2Labels.map((label) => label.document_id)); const missingGenerated = [...documentIds].filter((id) => !generatedDocumentIds.has(id)); + const missingSmartV2 = [...documentIds].filter((id) => !smartV2DocumentIds.has(id)); const allowedSiteMissingDocs = [...allowedSiteMissing].filter( (id) => !siteDocumentIds.has(id) && documentIds.has(id), ); @@ -208,10 +212,15 @@ async function main() { indexed_without_generated: missingGenerated.length, indexed_without_site: missingSite.length, indexed_without_document_type: missingDocumentType.length, + smart_v2_label_rows: smartV2Labels.length, + smart_v2_documents: smartV2DocumentIds.size, + indexed_without_smart_v2: missingSmartV2.length, labels_by_type: countByLabelType(labels), + smart_v2_labels_by_type: countByLabelType(smartV2Labels), sample_missing_generated: missingGenerated.slice(0, 10), sample_missing_site: missingSite.slice(0, 10), sample_missing_document_type: missingDocumentType.slice(0, 10), + sample_missing_smart_v2: missingSmartV2.slice(0, 10), allowed_site_missing: allowedSiteMissing.size, allowed_document_type_missing: allowedDocumentTypeMissing.size, allowed_site_missing_docs: allowedSiteMissingDocs, @@ -229,11 +238,19 @@ async function main() { console.log(`Indexed without generated labels: ${report.indexed_without_generated}`); console.log(`Indexed without site label: ${report.indexed_without_site}`); console.log(`Indexed without document_type label: ${report.indexed_without_document_type}`); + console.log(`Smart-v2 label rows: ${report.smart_v2_label_rows}`); + console.log(`Documents with smart-v2 labels: ${report.smart_v2_documents}`); + console.log(`Indexed without smart-v2 labels: ${report.indexed_without_smart_v2}`); console.log( `Labels by type: ${Object.entries(report.labels_by_type) .map(([type, count]) => `${type}=${count}`) .join(", ")}`, ); + console.log( + `Smart-v2 labels by type: ${Object.entries(report.smart_v2_labels_by_type) + .map(([type, count]) => `${type}=${count}`) + .join(", ")}`, + ); if (allowedSiteMissingDocs.length) { console.log(`Allowed indexed docs without site labels (from allowlist): ${allowedSiteMissingDocs.length}`); } diff --git a/scripts/classify-documents.ts b/scripts/classify-documents.ts index 14d0c03f1a..c399dfc2cb 100644 --- a/scripts/classify-documents.ts +++ b/scripts/classify-documents.ts @@ -59,6 +59,10 @@ const generatedLabelTypes = [ "workflow", "medication", "risk", + "clinical_action", + "care_phase", + "document_intent", + "content_feature", ] as const; async function loadAdminClient() { @@ -233,8 +237,19 @@ function generatedLabelsForPlan(plan: ClassificationPlan, stampedAt: string): Ge ); const secondaryLabels = plan.classification.labels.filter( (label) => - ["population", "topic", "setting", "service", "workflow", "medication", "risk"].includes(label.label_type) && - label.confidence >= 0.5, + [ + "population", + "topic", + "setting", + "service", + "workflow", + "medication", + "risk", + "clinical_action", + "care_phase", + "document_intent", + "content_feature", + ].includes(label.label_type) && label.confidence >= 0.5, ); return [...siteLabels, ...typeLabels, ...secondaryLabels].map((label) => ({ diff --git a/scripts/deployment-boot-smoke.mjs b/scripts/deployment-boot-smoke.mjs index 9469c30a10..66950e5b09 100644 --- a/scripts/deployment-boot-smoke.mjs +++ b/scripts/deployment-boot-smoke.mjs @@ -27,6 +27,7 @@ const pollDelayMs = parsePositiveInt("DEPLOY_SMOKE_POLL_DELAY_MS", 1000); const logRoot = mkdtempSync(resolve(tmpdir(), "clinical-kb-deploy-smoke-")); const logPath = resolve(logRoot, "deploy-smoke.log"); const nextBin = resolve(projectRoot, "node_modules", "next", "dist", "bin", "next"); +const requiredProductionEnv = ["SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY"]; if (!existsSync(nextBin)) { throw new Error(`Next.js binary not found at: ${nextBin}`); @@ -52,6 +53,18 @@ function formatFailureMessage(error) { return error instanceof Error ? error.message : `Deployment boot smoke failed: ${String(error)}`; } +function missingRequiredProductionEnv() { + return requiredProductionEnv.filter((name) => !process.env[name]?.trim()); +} + +function cleanupLogRoot() { + try { + rmSync(logRoot, { force: true, recursive: true }); + } catch { + // best-effort cleanup + } +} + async function stopServer(child, logStream) { if (child.exitCode === null) { if (process.platform === "win32" && child.pid) { @@ -60,7 +73,9 @@ async function stopServer(child, logStream) { windowsHide: true, }); await Promise.race([ - once(killer, "exit").then(() => true).catch(() => true), + once(killer, "exit") + .then(() => true) + .catch(() => true), delay(5000).then(() => false), ]); } else { @@ -68,7 +83,9 @@ async function stopServer(child, logStream) { } const terminated = await Promise.race([ - once(child, "exit").then(() => true).catch(() => true), + once(child, "exit") + .then(() => true) + .catch(() => true), delay(5000).then(() => false), ]); if (!terminated && child.exitCode === null && process.platform !== "win32") { @@ -84,29 +101,33 @@ async function stopServer(child, logStream) { } async function bootSmoke() { + const missingEnv = missingRequiredProductionEnv(); + if (missingEnv.length > 0) { + throw new Error( + `Deployment boot smoke requires production server env: ${missingEnv.join( + ", ", + )}. Configure GitHub Actions secrets for release/main deployment checks or skip this smoke outside those contexts.`, + ); + } + const logStream = createWriteStream(logPath, { flags: "a", encoding: "utf8" }); let spawnError = null; - const child = spawn( - process.execPath, - [nextBin, "start", "--hostname", "127.0.0.1", "--port", String(port)], - { - cwd: projectRoot, - env: { - ...process.env, - PORT: String(port), - NEXT_PUBLIC_SUPABASE_URL: - process.env.NEXT_PUBLIC_SUPABASE_URL ?? "https://sjrfecxgysukkwxsowpy.supabase.co", - // instrumentation.ts register() requires these in production mode; provide - // placeholder values so the boot-smoke can verify server identity without - // needing real secrets. Routes that actually use Supabase/OpenAI will still - // fail with real errors, but /api/local-project-id does not. - SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "placeholder-ci-service-role", - OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "placeholder-ci-openai", - }, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, + const child = spawn(process.execPath, [nextBin, "start", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: projectRoot, + env: { + ...process.env, + PORT: String(port), + NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL ?? "https://sjrfecxgysukkwxsowpy.supabase.co", + // instrumentation.ts register() requires these in production mode; provide + // placeholder values so the boot-smoke can verify server identity without + // needing real secrets. Routes that actually use Supabase/OpenAI will still + // fail with real errors, but /api/local-project-id does not. + SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "placeholder-ci-service-role", + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "placeholder-ci-openai", }, - ); + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); child.once("error", (error) => { spawnError = error; }); @@ -173,21 +194,13 @@ async function bootSmoke() { } } -function cleanupLogRoot() { - try { - rmSync(logRoot, { force: true, recursive: true }); - } catch { - // best-effort cleanup - } -} - try { await bootSmoke(); cleanupLogRoot(); process.exit(0); } catch (error) { console.error(formatFailureMessage(error)); - dumpLogTail(); // log still exists here — cleanup happens after the dump + dumpLogTail(); cleanupLogRoot(); process.exit(1); } diff --git a/scripts/ensure-local-server.mjs b/scripts/ensure-local-server.mjs index 3d9ffbbb0b..a1ba14e25c 100644 --- a/scripts/ensure-local-server.mjs +++ b/scripts/ensure-local-server.mjs @@ -16,8 +16,11 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), " const maxPort = 65535; const identityPath = "/api/local-project-id"; const logPath = path.join(projectRoot, "dev-server.log"); +const startupLockPath = path.join(projectRoot, "tmp", "ensure-local-server.lock"); const printUrlOnly = process.argv.slice(2).includes("--print-url"); const debugEnabled = process.env.ENSURE_DEBUG === "1"; +const startupLockStaleMs = 3 * 60 * 1000; +const readyStableMs = 5 * 1000; function debug(message) { if (debugEnabled) console.error(`[ensure-local-server] ${message}`); @@ -94,6 +97,36 @@ function requestJson(url, timeoutMs = 3500) { }); } +function requestOk(url, timeoutMs = 8000) { + return new Promise((resolve) => { + let settled = false; + let request; + + const settle = (value) => { + if (settled) return; + settled = true; + clearTimeout(fallback); + resolve(value); + }; + + const fallback = setTimeout(() => { + request?.destroy(); + settle(false); + }, timeoutMs + 500); + + request = http.get(url, { timeout: timeoutMs }, (response) => { + response.resume(); + response.on("end", () => settle(response.statusCode >= 200 && response.statusCode < 400)); + }); + + request.on("timeout", () => { + request.destroy(); + settle(false); + }); + request.on("error", () => settle(false)); + }); +} + async function isThisProject(port, attempts = 3) { for (let attempt = 0; attempt < attempts; attempt += 1) { const payload = await requestJson(`http://localhost:${port}${identityPath}`); @@ -119,6 +152,41 @@ async function findStartPort(startPort) { throw new Error(`No free local port found from ${startPort} to ${maxPort}.`); } +async function acquireStartupLock() { + fs.mkdirSync(path.dirname(startupLockPath), { recursive: true }); + const startedAt = Date.now(); + + while (Date.now() - startedAt < startupLockStaleMs) { + try { + const fd = fs.openSync(startupLockPath, "wx"); + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); + fs.closeSync(fd); + debug(`acquired startup lock ${startupLockPath}`); + return () => { + try { + fs.rmSync(startupLockPath, { force: true }); + debug(`released startup lock ${startupLockPath}`); + } catch (error) { + debug(`failed to release startup lock: ${error?.message ?? error}`); + } + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + + const ageMs = Date.now() - (fs.statSync(startupLockPath, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()); + if (ageMs > startupLockStaleMs) { + debug(`removing stale startup lock after ${Math.round(ageMs)}ms`); + fs.rmSync(startupLockPath, { force: true }); + continue; + } + + await sleep(500); + } + } + + throw new Error(`Timed out waiting for ${startupLockPath}. Another startup may be stuck.`); +} + function startDevServer(port) { debug(`starting dev server on ${port}`); const out = fs.openSync(logPath, "a"); @@ -139,8 +207,20 @@ function startDevServer(port) { } async function waitForProject(port) { - for (let attempt = 0; attempt < 90; attempt += 1) { - if (await isThisProject(port, 1)) return true; + let stableSince = null; + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await isThisProject(port, 1)) { + stableSince ??= Date.now(); + const stableForMs = Date.now() - stableSince; + if (stableForMs >= readyStableMs) { + const rootReady = await requestOk(localUrl(port)); + debug(`root readiness on ${port}: ${rootReady}`); + if (rootReady && (await isPortBusy(port))) return true; + stableSince = null; + } + } else { + stableSince = null; + } debug(`waiting for project on ${port}: attempt ${attempt + 1}`); await sleep(500); } @@ -154,34 +234,60 @@ async function main() { debug(`existing port ${existingPort ?? "none"}`); if (existingPort) { - console.log(printUrlOnly ? localUrl(existingPort) : `Clinical KB is already running at ${localUrl(existingPort)}`); - return 0; + if (await waitForProject(existingPort)) { + console.log( + printUrlOnly ? localUrl(existingPort) : `Clinical KB is already running at ${localUrl(existingPort)}`, + ); + return 0; + } } - const target = await findStartPort(stablePort); - debug(`target ${target.port}, alreadyRunning=${target.alreadyRunning}`); + const releaseStartupLock = await acquireStartupLock(); - if (target.alreadyRunning) { - console.log(printUrlOnly ? localUrl(target.port) : `Clinical KB is already running at ${localUrl(target.port)}`); - return 0; - } + try { + const lockedExistingPort = await findExistingProjectServer(stablePort); + debug(`locked existing port ${lockedExistingPort ?? "none"}`); - if (target.port !== stablePort && !printUrlOnly) { - console.log( - `Stable project port ${stablePort} is serving another local project; starting Clinical KB at ${localUrl(target.port)}`, - ); - } + if (lockedExistingPort && (await waitForProject(lockedExistingPort))) { + console.log( + printUrlOnly + ? localUrl(lockedExistingPort) + : `Clinical KB is already running at ${localUrl(lockedExistingPort)}`, + ); + return 0; + } - startDevServer(target.port); + const target = await findStartPort(stablePort); + debug(`target ${target.port}, alreadyRunning=${target.alreadyRunning}`); - if (await waitForProject(target.port)) { - console.log(printUrlOnly ? localUrl(target.port) : `Clinical KB is running at ${localUrl(target.port)}`); - if (!printUrlOnly) console.log(`Server log: ${logPath}`); - return 0; - } + if (target.alreadyRunning) { + if (await waitForProject(target.port)) { + console.log( + printUrlOnly ? localUrl(target.port) : `Clinical KB is already running at ${localUrl(target.port)}`, + ); + return 0; + } + } - console.error(`Clinical KB did not become ready at ${localUrl(target.port)}. Check ${logPath}`); - return 1; + if (target.port !== stablePort && !printUrlOnly) { + console.log( + `Stable project port ${stablePort} is serving another local project; starting Clinical KB at ${localUrl(target.port)}`, + ); + } + + startDevServer(target.port); + + if (await waitForProject(target.port)) { + console.log(printUrlOnly ? localUrl(target.port) : `Clinical KB is running at ${localUrl(target.port)}`); + if (!printUrlOnly) console.log(`Server log: ${logPath}`); + return 0; + } + + console.error(`Clinical KB did not become ready at ${localUrl(target.port)}. Check ${logPath}`); + return 1; + } finally { + releaseStartupLock(); + } } process.exitCode = await main(); diff --git a/scripts/eval-rag.ts b/scripts/eval-rag.ts index aac1f42f55..261f37eed9 100644 --- a/scripts/eval-rag.ts +++ b/scripts/eval-rag.ts @@ -284,10 +284,12 @@ async function main() { [ ` Diagnostics: expected=${result.expectedFiles.join(", ") || "none"}`, `missing=${result.missingFiles.join(", ") || "none"}`, - `topFiles=${result.retrievedSources - .slice(0, 5) - .map((source) => `${source.rank}:${source.fileName}`) - .join(" | ") || "none"}`, + `topFiles=${ + result.retrievedSources + .slice(0, 5) + .map((source) => `${source.rank}:${source.fileName}`) + .join(" | ") || "none" + }`, `route=${result.route}`, `grounded=${result.grounded}`, `citations=${result.citations}`, diff --git a/scripts/guard-next-build.mjs b/scripts/guard-next-build.mjs new file mode 100644 index 0000000000..6006235ee5 --- /dev/null +++ b/scripts/guard-next-build.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { appName, localProjectId, projectPortEnd, stableProjectPort } from "./local-server-utils.mjs"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const expectedProjectId = localProjectId(projectRoot); +const identityPath = "/api/local-project-id"; +const timeoutMs = 350; + +function requestJson(port) { + return new Promise((resolve) => { + let settled = false; + let request; + + const settle = (value) => { + if (settled) return; + settled = true; + clearTimeout(fallback); + resolve(value); + }; + + const fallback = setTimeout(() => { + request?.destroy(); + settle(null); + }, timeoutMs + 100); + + request = http.get(`http://localhost:${port}${identityPath}`, { timeout: timeoutMs }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => { + try { + settle(JSON.parse(body)); + } catch { + settle(null); + } + }); + }); + + request.on("timeout", () => { + request.destroy(); + settle(null); + }); + request.on("error", () => settle(null)); + }); +} + +async function findRunningProjectServer() { + const stablePort = stableProjectPort(projectRoot); + + for (let port = stablePort; port <= projectPortEnd; port += 1) { + const payload = await requestJson(port); + if (payload?.appName === appName && payload?.projectId === expectedProjectId) return port; + } + + return null; +} + +if (process.env.ALLOW_BUILD_WITH_DEV_SERVER === "1") { + console.warn("ALLOW_BUILD_WITH_DEV_SERVER=1 is set; continuing even if the local dev server is running."); + process.exit(0); +} + +const runningPort = await findRunningProjectServer(); + +if (runningPort) { + console.error( + [ + `Refusing to run next build while ${appName} dev server is running at http://localhost:${runningPort}.`, + "Stop the dev server first, or set ALLOW_BUILD_WITH_DEV_SERVER=1 if this cache churn is intentional.", + ].join("\n"), + ); + process.exit(1); +} diff --git a/scripts/run-eval-safe.mjs b/scripts/run-eval-safe.mjs new file mode 100644 index 0000000000..0d13fe29c2 --- /dev/null +++ b/scripts/run-eval-safe.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const isWindows = process.platform === "win32"; +const [targetScript, ...forwardArgs] = process.argv.slice(2); + +if (!targetScript) { + console.error("Usage: node scripts/run-eval-safe.mjs