diff --git a/.env.example b/.env.example index e6062d5bc..e56b6a601 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,9 @@ RAG_AWAIT_QUERY_LOGS=false # Privacy / production safety # Explicit demo opt-in. Blocked by npm run check:production-readiness in production. #NEXT_PUBLIC_DEMO_MODE=false +# Design-exploration mockup routes (/mockups/*) 404 in production builds unless +# explicitly opted in. Always reachable in dev/test. +#NEXT_PUBLIC_MOCKUPS_ENABLED=false # Persist raw clinical query text in logs. Default off; blocked in production readiness. #RAG_PERSIST_RAW_QUERY_TEXT=false # Server-side key for the redacted query-hash placeholder (min 16 chars). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39cc1d28d..b54c429b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,12 @@ jobs: - name: Runtime alignment run: npm run check:runtime + - name: Dependency vulnerability audit + # Non-mutating gate. The document parsers (pdf-parse, pdfjs-dist, mammoth, + # exceljs, jszip) process untrusted uploads, so high/critical advisories in + # the production tree must block the merge rather than wait for Dependabot. + run: npm audit --omit=dev --audit-level=high + - name: Edge function typecheck run: npm run check:edge:functions diff --git a/.gitignore b/.gitignore index d0afa73df..805ef4bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -79,5 +79,7 @@ artifacts/ # Local debugging scratch space — never commit (accidentally landed once via 'Save Codex local changes') scratch/ .qa-smoke/ +# Visual-QA capture scratch (scripts + screenshots) — untracked 2026-07-06 after 24 files landed +.tmp-visual/ *.pid tmp_output.txt diff --git a/.tmp-visual/capture.mjs b/.tmp-visual/capture.mjs deleted file mode 100644 index bcb0354dc..000000000 --- a/.tmp-visual/capture.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/desktop-home.png', fullPage: true }); - - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/mobile-home.png', fullPage: true }); - await browser.close(); -})(); diff --git a/.tmp-visual/check-search-api.mjs b/.tmp-visual/check-search-api.mjs deleted file mode 100644 index 57eec52ec..000000000 --- a/.tmp-visual/check-search-api.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); - const events = []; - page.on('response', async (resp) => { - const url = resp.url(); - if (url.includes('/api/')) { - try { - const text = await resp.text(); - events.push({ - url, - status: resp.status(), - ok: resp.ok(), - text: text.slice(0, 220), - }); - } catch { - events.push({ - url, - status: resp.status(), - ok: resp.ok(), - text: '', - }); - } - } - }); - - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - const input = page.locator('input[placeholder="Ask a question"]'); - await input.fill('What monitoring is required after starting lithium?'); - - const askButton = page.locator('button[aria-label="Generate source-backed answer"]').first(); - await askButton.click(); - - await page.waitForTimeout(12000); - await page.waitForLoadState('networkidle'); - - const state = await page.evaluate(() => ({ - body: (document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 2200), - statusLine: document.querySelector('.text-sm, p, h2, h3')?.textContent || '', - hasResultsText: (document.body.innerText || '').includes('Retrieved passages appear after a question') || false, - headingText: Array.from(document.querySelectorAll('h2, h3')).map((el) => (el.textContent || '').trim()), - ctaText: Array.from(document.querySelectorAll('button, a')).map((el) => (el.textContent || '').trim()).filter((t) => /Open source|Source PDF|Add scope|Ask|Search|Citations|Sources|Gaps|Verify/.test(t)), - })); - - await browser.close(); - - console.log('RESULTS:' + JSON.stringify({ - api: events.filter((e) => e.url.includes('/api/search') || e.url.includes('/api/tools') || e.url.includes('/api/documents') || e.url.includes('/api/local-project-id')), - state, - }, null, 2)); -})(); diff --git a/.tmp-visual/desktop-home.png b/.tmp-visual/desktop-home.png deleted file mode 100644 index 20b9d878f..000000000 Binary files a/.tmp-visual/desktop-home.png and /dev/null differ diff --git a/.tmp-visual/final-query-desktop.png b/.tmp-visual/final-query-desktop.png deleted file mode 100644 index 085258ed2..000000000 Binary files a/.tmp-visual/final-query-desktop.png and /dev/null differ diff --git a/.tmp-visual/final-query-mobile.png b/.tmp-visual/final-query-mobile.png deleted file mode 100644 index 7cccbc4d3..000000000 Binary files a/.tmp-visual/final-query-mobile.png and /dev/null differ diff --git a/.tmp-visual/inspect-ask-controls.mjs b/.tmp-visual/inspect-ask-controls.mjs deleted file mode 100644 index 76d2eca24..000000000 --- a/.tmp-visual/inspect-ask-controls.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - await page.goto('http://localhost:4298', { waitUntil: 'domcontentloaded' }); - - const input = page.locator('input[placeholder="Ask a question"]'); - await input.waitFor({ state: 'attached', timeout: 12000 }); - - const controls = await page.evaluate(() => { - const inp = Array.from(document.querySelectorAll('input')).find((el) => el.getAttribute('placeholder') === 'Ask a question'); - if (!inp) return null; - const form = inp.closest('form'); - const allButtons = Array.from(document.querySelectorAll('button, input[type="submit"]')).map((el) => ({ - text: (el.textContent || '').trim(), - type: el.getAttribute('type') || (el.tagName === 'BUTTON' ? 'button' : ''), - value: el.getAttribute('value') || '', - aria: el.getAttribute('aria-label') || '', - id: el.id || '', - })); - const formButtons = form ? Array.from(form.querySelectorAll('button, input[type="submit"]')).map((el) => ({ - text: (el.textContent || '').trim(), - type: el.getAttribute('type') || (el.tagName === 'BUTTON' ? 'button' : ''), - value: el.getAttribute('value') || '', - aria: el.getAttribute('aria-label') || '', - id: el.id || '', - })) : []; - - return { - inputCount: document.querySelectorAll('input[placeholder="Ask a question"]').length, - inputOuterHTML: inp.outerHTML.slice(0, 400), - formAction: form ? form.getAttribute('action') : null, - formMethod: form ? form.getAttribute('method') : null, - formButtons, - allButtons: allButtons.slice(0, 80), - }; - }); - - console.log(JSON.stringify(controls, null, 2)); - await browser.close(); -})(); diff --git a/.tmp-visual/inspect-home.mjs b/.tmp-visual/inspect-home.mjs deleted file mode 100644 index 11a37c4e4..000000000 --- a/.tmp-visual/inspect-home.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const page = await chromium.launch({ headless: true }).then(async (browser) => { - const p = await browser.newPage(); - await p.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - const body = await p.evaluate(() => document.body.innerText || ''); - const links = await p.evaluate(() => { - return Array.from(document.querySelectorAll('a')).map((a) => ({ - text: (a.textContent || '').trim(), - href: a.getAttribute('href') || '', - })).filter((x) => x.text || x.href); - }); - - const buttons = await p.evaluate(() => { - return Array.from(document.querySelectorAll('button')).map((b) => ({ - text: (b.textContent || '').trim(), - type: b.getAttribute('type') || '', - })); - }); - - const inputs = await p.evaluate(() => Array.from(document.querySelectorAll('input, textarea')).map((el) => ({ - tag: el.tagName, - type: el.getAttribute('type') || '', - placeholder: el.getAttribute('placeholder') || '', - id: el.id || '', - name: el.getAttribute('name') || '', - }))); - - console.log('TITLE:' + (await p.title())); - console.log('URL:' + p.url()); - console.log('HREFS:' + JSON.stringify(links.slice(0, 80), null, 2)); - console.log('BUTTONS:' + JSON.stringify(buttons.slice(0, 80), null, 2)); - console.log('INPUTS:' + JSON.stringify(inputs, null, 2)); - console.log('TEXT_SNIPPET_START'); - console.log(body.slice(0, 1200)); - console.log('TEXT_SNIPPET_END'); - - await browser.close(); - }); -})(); diff --git a/.tmp-visual/mobile-home.png b/.tmp-visual/mobile-home.png deleted file mode 100644 index 9281eaef8..000000000 Binary files a/.tmp-visual/mobile-home.png and /dev/null differ diff --git a/.tmp-visual/query-answer-desktop.png b/.tmp-visual/query-answer-desktop.png deleted file mode 100644 index fbe0f5276..000000000 Binary files a/.tmp-visual/query-answer-desktop.png and /dev/null differ diff --git a/.tmp-visual/query-answer-mobile.png b/.tmp-visual/query-answer-mobile.png deleted file mode 100644 index adc32bb50..000000000 Binary files a/.tmp-visual/query-answer-mobile.png and /dev/null differ diff --git a/.tmp-visual/query-answer-stream.mjs b/.tmp-visual/query-answer-stream.mjs deleted file mode 100644 index 25d3cda0b..000000000 --- a/.tmp-visual/query-answer-stream.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import { chromium } from "playwright"; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); - - let reqPayload = null; - let respSnippet = null; - - page.on('request', (req) => { - if (req.url().includes('/api/answer/stream')) { - reqPayload = { - method: req.method(), - postData: req.postData(), - }; - } - }); - - page.on('response', async (resp) => { - if (resp.url().includes('/api/answer/stream')) { - try { - respSnippet = await resp.text(); - } catch (error) { - respSnippet = "read-failed"; - } - } - }); - - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - await page.fill('input[placeholder="Ask a question"]', 'What monitoring is required after starting lithium?'); - await page.locator('button[aria-label="Generate source-backed answer"]').click(); - await page.waitForTimeout(5000); - - const statusText = await page.evaluate(() => (document.body.innerText || '').replace(/\s+/g, ' ')); - - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-stream-desktop.png', fullPage: true }); - await browser.close(); - - console.log('RESULTS:' + JSON.stringify({ - request: reqPayload, - responseSnippet: (respSnippet || '').slice(0, 1200), - statusText: statusText.slice(0, 2200), - hasNoDataMarkers: { - noPassages: statusText.includes('Retrieved passages appear after a question'), - searching: statusText.includes('Searching indexed documents'), - notReady: statusText.includes('Search setup is not ready') || statusText.includes('setup is not ready') - } - }, null, 2)); -})(); diff --git a/.tmp-visual/query-api-trace.mjs b/.tmp-visual/query-api-trace.mjs deleted file mode 100644 index 64d8591e9..000000000 --- a/.tmp-visual/query-api-trace.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); - const events = []; - - page.on('response', async (resp) => { - const url = resp.url(); - if (url.includes('/api/')) { - events.push({ - url, - status: resp.status(), - ok: resp.ok(), - }); - } - }); - - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - const input = page.locator('input[placeholder="Ask a question"]'); - await input.fill('What monitoring is required after starting lithium?'); - - const askButton = page.locator('button[aria-label="Generate source-backed answer"]'); - await askButton.click(); - - await page.waitForTimeout(15000); - await page.waitForLoadState('networkidle'); - - const endText = await page.evaluate(() => ({ - text: (document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 2200), - hasResultPhrase: (document.body.innerText || '').includes('Retrieved passages appear after a question') || (document.body.innerText || '').includes('The answer, quotes, source PDFs, and diagrams will appear here.'), - queryValue: (document.querySelector('input[placeholder="Ask a question"]')?.value || ''), - })); - - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-post-click-desktop.png', fullPage: true }); - - await browser.close(); - const interesting = events.filter((event) => event.url.includes('/api/search') || event.url.includes('/api/search/')); - const all = events.slice(-20); - - console.log('RESULTS:' + JSON.stringify({ endText, interesting, all }, null, 2)); -})(); diff --git a/.tmp-visual/query-ask-submit.mjs b/.tmp-visual/query-ask-submit.mjs deleted file mode 100644 index 23d99285a..000000000 --- a/.tmp-visual/query-ask-submit.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - - const input = page.locator('input[placeholder="Ask a question"]'); - await input.fill('What monitoring is required after starting lithium?'); - - const askButton = page.locator('button[aria-label="Generate source-backed answer"]').first(); - await askButton.waitFor({ state: 'visible', timeout: 12000 }); - await askButton.click(); - - // Wait for first response indicator - await page.waitForTimeout(1200); - await page.waitForSelector('text=Checking indexed library before showing document status', { timeout: 1000 }).catch(() => {}); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2500); - - const state = await page.evaluate(() => { - const text = (document.body.innerText || '').replace(/\s+/g, ' '); - const phrase = 'Sourced synthesis with quotes, PDFs, and indexed diagrams.'; - const beforeQ = text.indexOf(phrase); - - const panelHeadings = Array.from(document.querySelectorAll('h2, h3')).map((el) => (el.textContent || '').trim()).filter(Boolean); - - const actionHints = Array.from(document.querySelectorAll('button, a')).map((el) => (el.textContent || '').trim()).filter((t) => t && /Open source|Add scope|Source PDF|Citations|Sources|Gaps|No linked citations|No source provenance|No source passages|quote cards|source passages|verify/i.test(t)); - - const allLabels = Array.from(new Set(actionHints)).slice(0, 80); - - return { - currentUrl: location.href, - headingCount: document.querySelectorAll('h2, h3, h4').length, - panelHeadings: panelHeadings.slice(0, 40), - labels: allLabels, - bodyPreview: document.body.innerText?.slice(0, 2800) || '', - }; - }); - - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-answer-desktop.png', fullPage: true }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-answer-mobile.png', fullPage: true }); - - await browser.close(); - console.log('RESULTS:' + JSON.stringify(state, null, 2)); -})(); diff --git a/.tmp-visual/query-ask.mjs b/.tmp-visual/query-ask.mjs deleted file mode 100644 index 61c39cea3..000000000 --- a/.tmp-visual/query-ask.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - - const q = 'What monitoring is required after starting lithium for bipolar disorder?'; - const input = page.locator('input[placeholder="Ask a question"]'); - await input.waitFor({ state: 'visible', timeout: 12000 }); - await input.fill(q); - const askBtn = page.getByRole('button', { name: /^ask$/i }).first(); - await askBtn.click(); - - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2500); - - await page.screenshot({ path: 'C:/Dev\Apps\Database/.tmp-visual/query-answer-desktop.png', fullPage: true }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ path: 'C:/Dev\Apps\Database/.tmp-visual/query-answer-mobile.png', fullPage: true }); - - const dump = await page.evaluate(() => { - const getText = (selector, limit = 500) => { - const el = document.querySelector(selector); - if (!el) return ''; - return (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, limit); - }; - - const outputRoot = document.querySelector('[data-testid="generated-answer"]') || - document.querySelector('[data-testid="rag-output"]') || - document.querySelector('main'); - - const labels = Array.from(document.querySelectorAll('button, a')).map((el) => (el.textContent || '').trim()) - .filter((t) => /Open source|Source PDF|Add scope|Citations|Sources|Gaps|No linked citations|No source provenance|No indexed clinically useful tables|No quote|No quotes|No passages|Verified|Evidence map/i.test(t)); - - const sections = Array.from(document.querySelectorAll('section, article')).map((section) => { - const heading = section.querySelector('h2, h3, h4')?.textContent?.trim() || ''; - const txt = (section.textContent || '').replace(/\s+/g, ' ').trim(); - if (!heading) return null; - return { heading, text: txt.slice(0, 260) }; - }).filter(Boolean).slice(0, 30); - - return { - url: location.href, - title: document.title, - queryInInput: (document.querySelector('input[placeholder="Ask a question"]')?.value || ''), - outputSnippet: outputRoot ? (outputRoot.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 1800) : '', - relevantLabels: [...new Set(labels)].slice(0, 50), - sectionSamples: sections, - firstError: document.body ? ((document.body.textContent || '').match(/error|failed|unauth|401|429|403/ig)?.[0] || '') : '' - }; - }); - - await browser.close(); - console.log('RESULTS:' + JSON.stringify(dump, null, 2)); -})(); diff --git a/.tmp-visual/query-disabled-desktop.png b/.tmp-visual/query-disabled-desktop.png deleted file mode 100644 index 27a575ed9..000000000 Binary files a/.tmp-visual/query-disabled-desktop.png and /dev/null differ diff --git a/.tmp-visual/query-disabled-state.mjs b/.tmp-visual/query-disabled-state.mjs deleted file mode 100644 index 584d7e187..000000000 --- a/.tmp-visual/query-disabled-state.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - - const input = page.locator('input[placeholder="Ask a question"]'); - await input.fill('What monitoring is required after starting lithium?'); - - const askButton = page.locator('button[aria-label="Generate source-backed answer"]'); - const info = await askButton.evaluate((btn) => ({ - disabled: btn.disabled, - text: (btn.textContent || '').trim(), - title: btn.getAttribute('title') || '', - aria: btn.getAttribute('aria-label') || '', - classes: btn.className, - })); - - const summary = await page.evaluate(() => ({ - bodyText: (document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 2200), - statusBanner: (document.querySelector('[role="status"], .status, .text-sm')?.textContent || '').replace(/\s+/g, ' ').trim(), - hasReadyText: (document.body.innerText || '').includes('setup is not ready'), - headings: Array.from(document.querySelectorAll('h2, h3')).map((el) => (el.textContent || '').trim()), - ctaCandidates: Array.from(document.querySelectorAll('button')).map((btn) => ({ - text: (btn.textContent || '').trim(), - title: btn.getAttribute('title') || '', - aria: btn.getAttribute('aria-label') || '', - disabled: !!btn.disabled, - })).filter((x) => /Generate source-backed answer|Open mobile document scope|Clear refine|Source passages|Ask/i.test(x.text) || /ready/.test(x.title)), - })); - - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-disabled-desktop.png', fullPage: true }); - await browser.close(); - - console.log('RESULTS:' + JSON.stringify({ button: info, summary }, null, 2)); -})(); diff --git a/.tmp-visual/query-final.mjs b/.tmp-visual/query-final.mjs deleted file mode 100644 index 4a7003052..000000000 --- a/.tmp-visual/query-final.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import { chromium } from 'playwright'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } }); - await page.goto('http://localhost:4298', { waitUntil: 'networkidle' }); - await page.fill('input[placeholder="Ask a question"]', 'What monitoring is required after starting lithium?'); - const ask = page.locator('button[aria-label="Generate source-backed answer"]'); - await ask.click(); - await page.waitForTimeout(5000); - - const output = await page.evaluate(() => { - const body = (document.body.innerText || '').replace(/\s+/g, ' '); - return { - hasSearching: body.includes('Searching indexed documents.'), - hasSearchReadyText: body.includes('Search setup not ready'), - hasNoPassagesText: body.includes('Retrieved passages appear after a question'), - query: document.querySelector('input[placeholder="Ask a question"]')?.value || '', - labels: Array.from(document.querySelectorAll('button, a')).map((el) => (el.textContent || '').trim()).filter((text) => /Open source|Source PDF|Add scope|Citations|Sources|Gaps|No linked citations|No source provenance|Search setup|Retrieved passages/.test(text)), - }; - }); - - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/final-query-desktop.png', fullPage: true }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/final-query-mobile.png', fullPage: true }); - await browser.close(); - - console.log('RESULTS:' + JSON.stringify(output, null, 2)); -})(); diff --git a/.tmp-visual/query-noinput-desktop.png b/.tmp-visual/query-noinput-desktop.png deleted file mode 100644 index 69675b89c..000000000 Binary files a/.tmp-visual/query-noinput-desktop.png and /dev/null differ diff --git a/.tmp-visual/query-noinput-mobile.png b/.tmp-visual/query-noinput-mobile.png deleted file mode 100644 index bb52352e3..000000000 Binary files a/.tmp-visual/query-noinput-mobile.png and /dev/null differ diff --git a/.tmp-visual/query-post-click-desktop.png b/.tmp-visual/query-post-click-desktop.png deleted file mode 100644 index 81f2c28c6..000000000 Binary files a/.tmp-visual/query-post-click-desktop.png and /dev/null differ diff --git a/.tmp-visual/query-smoke.mjs b/.tmp-visual/query-smoke.mjs deleted file mode 100644 index 0a7a50ac6..000000000 --- a/.tmp-visual/query-smoke.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import { chromium } from 'playwright'; - -const APP_URL = 'http://localhost:4298'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - - await page.goto(APP_URL, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1200); - - const querySelectors = [ - 'input[type="text"]', - 'input[placeholder*="Search" i]', - 'textarea', - '[data-testid="clinical-search-input"]', - '[name="q"]', - 'input[role="searchbox"]' - ]; - - let input = null; - for (const selector of querySelectors) { - const el = page.locator(selector).first(); - if (await el.count()) { - input = el; - break; - } - } - - if (!input) { - const allInputs = page.locator('input, textarea'); - const cnt = await allInputs.count(); - console.log(JSON.stringify({ status: 'no-query-input-found', inputCount: cnt })); - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-noinput-desktop.png', fullPage: true }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-noinput-mobile.png', fullPage: true }); - await browser.close(); - process.exit(0); - } - - await input.fill('acute mania first line pharmacologic treatment'); - - const submitSelectors = [ - 'button:has-text("Search")', - 'button[type="submit"]', - 'form button', - '[data-testid="clinical-search-submit"]' - ]; - - for (const selector of submitSelectors) { - const btn = page.locator(selector).first(); - if (await btn.count()) { - await btn.click({ timeout: 1500 }).catch(async () => { - await page.keyboard.press('Enter'); - }); - break; - } - } - - if (!page.url().includes('/search') && !page.url().includes('/results')) { - await page.keyboard.press('Enter'); - } - - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1500); - - const desktopText = await page.evaluate(() => { - const texts = Array.from(document.querySelectorAll('body *')) - .map((node) => (node.textContent || '').trim()) - .filter(Boolean); - - const compact = texts.filter((t) => - /Open source|Source PDF|Add scope|No linked citations|No source provenance|Citations|Sources|Gaps|Verify/.test(t), - ); - - const preview = compact.slice(0, 60); - const headings = Array.from(document.querySelectorAll('h1, h2, h3')).map((h) => h.textContent?.trim()).filter(Boolean); - - return { compact: preview, headings, hasSourceButtons: !!document.querySelector('button:has-text("Open source")') }; - }); - - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-result-desktop.png', fullPage: true }); - - const openSourceButtons = await page.locator('button:has-text("Open source"), a:has-text("Open source"), a:has-text("Source PDF"), button:has-text("Add scope")').count(); - - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ path: 'C:/Dev/Apps/Database/.tmp-visual/query-result-mobile.png', fullPage: true }); - - const actionButtonLabels = await page.evaluate(() => { - return Array.from(document.querySelectorAll('button, a')).map((el) => (el.textContent || '').trim()).filter((t) => /(Open source|Source PDF|Add scope|Citations|No linked citations|No source provenance)/.test(t)); - }); - - await browser.close(); - - console.log('RESULTS:' + JSON.stringify({ - homeURL: APP_URL, - currentURL: page.url(), - headings: desktopText.headings.slice(0, 30), - compactHints: desktopText.compact.slice(0, 60), - openSourceButtons, - actionButtonLabels: Array.from(new Set(actionButtonLabels)).slice(0, 30), - }, null, 2)); -})(); diff --git a/.tmp-visual/query-stream-desktop.png b/.tmp-visual/query-stream-desktop.png deleted file mode 100644 index 1d45b91e8..000000000 Binary files a/.tmp-visual/query-stream-desktop.png and /dev/null differ diff --git a/.tmp-visual/stream-capture.mjs b/.tmp-visual/stream-capture.mjs deleted file mode 100644 index 3ffc54bf9..000000000 --- a/.tmp-visual/stream-capture.mjs +++ /dev/null @@ -1,44 +0,0 @@ -const query = 'What monitoring is required after starting lithium?'; - -(async () => { - const ctrl = new AbortController(); - setTimeout(() => ctrl.abort(), 12000); - - try { - const res = await fetch('http://localhost:4298/api/answer/stream', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ query, filters: {}, queryMode: 'auto' }), - signal: ctrl.signal, - }); - - console.log('STATUS', res.status, res.ok); - if (!res.body) { - console.log('NO_BODY'); - return; - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let output = ''; - let done = false; - - while (!done && output.length < 20000) { - const { value, done: d } = await reader.read(); - done = d; - if (value) { - output += decoder.decode(value, { stream: true }); - } - } - - console.log('STREAM_PREVIEW_START'); - console.log(output.slice(0, 3000)); - console.log('STREAM_PREVIEW_END'); - } catch (error) { - if (String(error.name) === 'AbortError') { - console.log('ABORTED_OK'); - } else { - console.error('ERROR', error && error.message ? error.message : String(error)); - } - } -})(); diff --git a/.tmp-visual/stream-headers.mjs b/.tmp-visual/stream-headers.mjs deleted file mode 100644 index 1fd3096f3..000000000 --- a/.tmp-visual/stream-headers.mjs +++ /dev/null @@ -1,25 +0,0 @@ -(async () => { - const query = 'What monitoring is required after starting lithium?'; - const abortMs = 6000; - const ctrl = new AbortController(); - setTimeout(() => ctrl.abort(), abortMs); - - try { - const res = await fetch('http://localhost:4298/api/answer/stream', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ query, filters: {}, queryMode: 'auto' }), - signal: ctrl.signal, - }); - console.log('status', res.status); - console.log('ok', res.ok); - console.log('type', res.headers.get('content-type')); - console.log('cache-control', res.headers.get('cache-control')); - console.log('connection', res.headers.get('connection')); - console.log('transfer-encoding', res.headers.get('transfer-encoding')); - console.log('x-powered-by', res.headers.get('x-powered-by')); - console.log('server', res.headers.get('server')); - } catch (error) { - console.log('ERR', String(error.name), String(error.message)); - } -})(); diff --git a/COLOR_REDESIGN_PLAN.md b/docs/archive/COLOR_REDESIGN_PLAN.md similarity index 99% rename from COLOR_REDESIGN_PLAN.md rename to docs/archive/COLOR_REDESIGN_PLAN.md index e9fb3322a..a25d91a3f 100644 --- a/COLOR_REDESIGN_PLAN.md +++ b/docs/archive/COLOR_REDESIGN_PLAN.md @@ -1,5 +1,5 @@ > **SUPERSEDED — historical exploration only.** Do not implement from this file. -> Active design direction: [`docs/redesign/02-design-direction.md`](docs/redesign/02-design-direction.md) +> Active design direction: [`docs/redesign/02-design-direction.md`](../redesign/02-design-direction.md) > (Clinical White / Aegean Graphite). # Luxury Black-First Color Redesign Plan (Global UI Polish) diff --git a/TOOLS_CONTEXT_FOR_NEW_CHAT.md b/docs/archive/TOOLS_CONTEXT_FOR_NEW_CHAT.md similarity index 100% rename from TOOLS_CONTEXT_FOR_NEW_CHAT.md rename to docs/archive/TOOLS_CONTEXT_FOR_NEW_CHAT.md diff --git a/docs/clinical-chat-ui-implementation-plan.md b/docs/archive/clinical-chat-ui-implementation-plan.md similarity index 97% rename from docs/clinical-chat-ui-implementation-plan.md rename to docs/archive/clinical-chat-ui-implementation-plan.md index 223660684..254992c9d 100644 --- a/docs/clinical-chat-ui-implementation-plan.md +++ b/docs/archive/clinical-chat-ui-implementation-plan.md @@ -2,7 +2,7 @@ Date: 2026-06-23 -> **Revised 2026-07-03 — colour aligned to Clinical White / Aegean Graphite.** The functional-colour and colour-specification sections below have been rewritten in-body onto the role-split token system: `--command` (graphite) for primary command, `--clinical-accent` (Aegean blue-teal) for clinical identity (evidence/selected/send/focus), and `--success` (green) for status only. See [`redesign/02-design-direction.md`](redesign/02-design-direction.md) and [`redesign/permanent-colour-direction.md`](redesign/permanent-colour-direction.md). The layout, interaction, and iteration plan are unchanged from the original 2026-06-23 draft. +> **Revised 2026-07-03 — colour aligned to Clinical White / Aegean Graphite.** The functional-colour and colour-specification sections below have been rewritten in-body onto the role-split token system: `--command` (graphite) for primary command, `--clinical-accent` (Aegean blue-teal) for clinical identity (evidence/selected/send/focus), and `--success` (green) for status only. See [`redesign/02-design-direction.md`](../redesign/02-design-direction.md) and [`redesign/permanent-colour-direction.md`](../redesign/permanent-colour-direction.md). The layout, interaction, and iteration plan are unchanged from the original 2026-06-23 draft. ## Purpose @@ -148,7 +148,7 @@ Reading constraints: ## Colour specification -Use the Clinical White / Aegean Graphite role tokens defined in `src/app/globals.css` (full palette and dark-mode values in [`redesign/permanent-colour-direction.md`](redesign/permanent-colour-direction.md)). Reference the tokens, not raw hex; the light values below are for orientation only. +Use the Clinical White / Aegean Graphite role tokens defined in `src/app/globals.css` (full palette and dark-mode values in [`redesign/permanent-colour-direction.md`](../redesign/permanent-colour-direction.md)). Reference the tokens, not raw hex; the light values below are for orientation only. | Role token | Light value | Use | | -------------------------------- | --------------------- | ------------------------------------------------------------------------------- | diff --git a/docs/clinical-chat-ui-phase-checklist.md b/docs/archive/clinical-chat-ui-phase-checklist.md similarity index 100% rename from docs/clinical-chat-ui-phase-checklist.md rename to docs/archive/clinical-chat-ui-phase-checklist.md diff --git a/design-qa.md b/docs/archive/design-qa.md similarity index 100% rename from design-qa.md rename to docs/archive/design-qa.md diff --git a/docs/phase-3-design-decision-log.md b/docs/archive/phase-3-design-decision-log.md similarity index 100% rename from docs/phase-3-design-decision-log.md rename to docs/archive/phase-3-design-decision-log.md diff --git a/docs/phase-6-reaudit-2026-06-29.md b/docs/archive/phase-6-reaudit-2026-06-29.md similarity index 100% rename from docs/phase-6-reaudit-2026-06-29.md rename to docs/archive/phase-6-reaudit-2026-06-29.md diff --git a/docs/search-rag-phase-0-baseline.md b/docs/archive/search-rag-phase-0-baseline.md similarity index 100% rename from docs/search-rag-phase-0-baseline.md rename to docs/archive/search-rag-phase-0-baseline.md diff --git a/docs/search-rag-phase-1-api-validation.md b/docs/archive/search-rag-phase-1-api-validation.md similarity index 100% rename from docs/search-rag-phase-1-api-validation.md rename to docs/archive/search-rag-phase-1-api-validation.md diff --git a/docs/search-rag-phase-2-answer-plan.md b/docs/archive/search-rag-phase-2-answer-plan.md similarity index 100% rename from docs/search-rag-phase-2-answer-plan.md rename to docs/archive/search-rag-phase-2-answer-plan.md diff --git a/docs/search-rag-phase-3-synthesis-output.md b/docs/archive/search-rag-phase-3-synthesis-output.md similarity index 100% rename from docs/search-rag-phase-3-synthesis-output.md rename to docs/archive/search-rag-phase-3-synthesis-output.md diff --git a/docs/search-rag-phase-4-canonical-render-policy.md b/docs/archive/search-rag-phase-4-canonical-render-policy.md similarity index 100% rename from docs/search-rag-phase-4-canonical-render-policy.md rename to docs/archive/search-rag-phase-4-canonical-render-policy.md diff --git a/docs/search-rag-phase-5-source-review-ux.md b/docs/archive/search-rag-phase-5-source-review-ux.md similarity index 100% rename from docs/search-rag-phase-5-source-review-ux.md rename to docs/archive/search-rag-phase-5-source-review-ux.md diff --git a/docs/search-rag-phase-5.5-retrieval-quality-source-selection.md b/docs/archive/search-rag-phase-5.5-retrieval-quality-source-selection.md similarity index 100% rename from docs/search-rag-phase-5.5-retrieval-quality-source-selection.md rename to docs/archive/search-rag-phase-5.5-retrieval-quality-source-selection.md diff --git a/docs/search-rag-phase-5.5b-retrieval-follow-up.md b/docs/archive/search-rag-phase-5.5b-retrieval-follow-up.md similarity index 100% rename from docs/search-rag-phase-5.5b-retrieval-follow-up.md rename to docs/archive/search-rag-phase-5.5b-retrieval-follow-up.md diff --git a/docs/search-rag-pre-phase-2-diff-classification.md b/docs/archive/search-rag-pre-phase-2-diff-classification.md similarity index 100% rename from docs/search-rag-pre-phase-2-diff-classification.md rename to docs/archive/search-rag-pre-phase-2-diff-classification.md diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 7c3f04874..053ed64a4 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -169,6 +169,14 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **Type-scale ratchet:** `node scripts/check-type-scale.mjs` baseline is **20 hits / 9 files** (was 168/22). Remaining hits are rem display headings (accepted exceptions) and one mockup file. UI PRs must not raise the count; flip `check:type-scale --strict` into `verify:cheap` if the accepted exceptions are ever tokenized. - **Cleared this pass:** dead launcher mobile detail rows now expand (aria-expanded disclosures); launcher detail dialog migrated to the `Sheet` primitive (focus trap/return-focus restored); launcher filter tablists gained `aria-controls` + a `role="tabpanel"` results region; styled `src/app/not-found.tsx` added (the `notFound()` calls in differentials no longer fall through to the unstyled default); `?page=abc` NaN leak in the document viewer clamped; `/services` off-palette preview deleted (dead export) and the live navigator's residual hardcodes tokenized; launcher icon tones moved from raw Tailwind palette classes to categorical `--type-*` / semantic danger triads (dark-mode + forced-colors correct); mockups layout emits `robots: noindex`. +## Repository hygiene + production surface pass (2026-07-06) + +- **Mockup routes gated out of production:** `/mockups/*` (27 design-exploration pages) previously shipped in production builds, hidden only by a `robots.ts` disallow. `src/app/mockups/layout.tsx` now calls `notFound()` unless `mockupsEnabled()` (`src/lib/env.ts`) — always on in dev/test, production requires explicit `NEXT_PUBLIC_MOCKUPS_ENABLED=true`. Verified end-to-end: production build serves HTTP 404 on mockup routes and 200 on real routes; `tests/env-mockups-gate.test.ts` guards the flag logic. +- **Dependency vulnerability gate in CI:** the `verify` job now runs `npm audit --omit=dev --audit-level=high` after runtime alignment. Rationale: the document parsers (`pdf-parse`, `pdfjs-dist`, `mammoth`, `exceljs`, `jszip`) process untrusted uploads, so high/critical advisories in the production tree should block merges rather than wait for the weekly Dependabot pass. +- **Scratch artifacts untracked:** 24 committed `.tmp-visual/` files (visual-QA capture scripts + PNGs) removed from tracking; `.tmp-visual/` added to `.gitignore` (it was already Prettier-ignored). +- **Docs archived:** superseded/completed planning docs moved to `docs/archive/` — root `COLOR_REDESIGN_PLAN.md` (superseded), `TOOLS_CONTEXT_FOR_NEW_CHAT.md` + `design-qa.md` (chat handoffs referencing machine-local paths), the completed `search-rag-phase-0…5.5b` + pre-phase-2 series, and the completed `clinical-chat-ui-*` phase docs. Living docs (`search-rag-master-plan.md`, `search-rag-master-context.md`, `clinical-chat-ui-component-map.md`) stay in `docs/`; no inbound references were broken (verified by repo-wide grep) and relative links inside moved files were retargeted. +- **Playwright port-finder IPv6 fix:** superseded — an equivalent fix landed on `main` first (`71e0ab0`, which also adds a Chromium executable override); `main`'s version is kept in the merge. + ## Universal search workstream — verification state (2026-07-06) - **Shipped on `claude/universal-search-algorithm-ryrps7`:** finding #11 interim fix (classifier-verdict memoization, 15-min TTL, errors not memoized — the "cheaper interim option" from the 2026-07-03 entry above), pre-clamp tiebreak completion in `selectRetrievalEvidence`, weak-match OR-augmentation (kill switch `RAG_TEXT_WEAK_OR_RELAXATION=false`), `similarity_origin` telemetry, shared `catalog-search` primitives replacing the four per-domain rankers, forms mode-kind honesty, tools dataset dedupe, `/api/search/universal` federated endpoint, and the cross-entity typeahead in `UniversalSearchCommandSurface`. diff --git a/src/app/mockups/layout.tsx b/src/app/mockups/layout.tsx index c7eceb8df..ec21ee5a5 100644 --- a/src/app/mockups/layout.tsx +++ b/src/app/mockups/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import type { ReactNode } from "react"; +import { mockupsEnabled } from "@/lib/env"; + import { MockupsLayoutClient } from "./mockups-layout-client"; // Design-exploration prototypes: shipped for shareability, but never indexed @@ -10,5 +13,8 @@ export const metadata: Metadata = { }; export default function MockupsLayout({ children }: { children: ReactNode }) { + if (!mockupsEnabled()) { + notFound(); + } return {children}; } diff --git a/src/lib/env.ts b/src/lib/env.ts index a37d23c85..67db76f22 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -15,6 +15,7 @@ const envSchema = z.object({ LOCAL_NO_AUTH_OWNER_ID: z.string().optional(), PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(), NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(), + NEXT_PUBLIC_MOCKUPS_ENABLED: z.enum(["true", "false"]).optional(), OPENAI_API_KEY: z.string().optional(), OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), // Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding @@ -212,3 +213,13 @@ export function publicWorkspaceOwnerId() { export function publicUploadsEnabled() { return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; } + +export function mockupsEnabled() { + // Design-exploration mockup routes (/mockups/*) are a development surface. + // They stay reachable in dev/test builds, but a production deploy 404s them + // unless explicitly opted in (mirrors the prod guard in isLocalNoAuthMode). + if (process.env.NODE_ENV !== "production") { + return true; + } + return env.NEXT_PUBLIC_MOCKUPS_ENABLED === "true"; +} diff --git a/tests/env-mockups-gate.test.ts b/tests/env-mockups-gate.test.ts new file mode 100644 index 000000000..dc9196ce6 --- /dev/null +++ b/tests/env-mockups-gate.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// mockupsEnabled() reads a frozen value from `env` (parsed at import time) plus +// live process.env.NODE_ENV, so each case re-imports the module with a fresh, +// stubbed environment (same pattern as env-demo-mode.test.ts). The /mockups/* +// design-exploration routes must 404 in production unless explicitly opted in. + +const ENV_KEYS = ["NODE_ENV", "NEXT_PUBLIC_MOCKUPS_ENABLED"] as const; + +async function loadEnv(overrides: Partial>) { + vi.resetModules(); + for (const key of ENV_KEYS) { + vi.stubEnv(key, overrides[key]); + } + return import("../src/lib/env"); +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("mockupsEnabled production guard", () => { + it("disables mockup routes in production by default", async () => { + const { mockupsEnabled } = await loadEnv({ NODE_ENV: "production" }); + expect(mockupsEnabled()).toBe(false); + }); + + it("honors an explicit opt-in in production", async () => { + const { mockupsEnabled } = await loadEnv({ + NODE_ENV: "production", + NEXT_PUBLIC_MOCKUPS_ENABLED: "true", + }); + expect(mockupsEnabled()).toBe(true); + }); + + it("keeps mockup routes reachable in development without opt-in", async () => { + const { mockupsEnabled } = await loadEnv({ NODE_ENV: "development" }); + expect(mockupsEnabled()).toBe(true); + }); + + it("keeps mockup routes reachable in test without opt-in", async () => { + const { mockupsEnabled } = await loadEnv({ NODE_ENV: "test" }); + expect(mockupsEnabled()).toBe(true); + }); +});