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);
+ });
+});