feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice

- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer
- Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support
- Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline
- Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots)
- Migrate core application to Svelte 5 runes (, , ) with reactive stores
- Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
This commit is contained in:
jpremill committed 2026-09-29 23:35:05 -04:00
1 parent c82b112624
commit 1feea40684
148 files changed
+11230 -1706

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Playwright Visual Snapshot Review Gallery Packager
*
* Scans docs/screenshots/<viewport>/*.png, generates an interactive,
* responsive HTML review gallery with viewport filters, comparison matrices,
* and high-DPI lightbox inspection.
*/
import fs from 'fs';
import path from 'path';
const screenshotsDir = path.resolve(process.cwd(), 'docs/screenshots');
const outputHtml = path.join(screenshotsDir, 'index.html');
if (!fs.existsSync(screenshotsDir)) {
console.log(`[Snapshot Gallery] No screenshots directory found at ${screenshotsDir}. Skipping gallery build.`);
process.exit(0);
}
const entries = [];
const viewports = fs.readdirSync(screenshotsDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
for (const vp of viewports) {
const vpDir = path.join(screenshotsDir, vp);
const files = fs.readdirSync(vpDir).filter(f => f.endsWith('.png'));
for (const file of files) {
const filePath = path.join(vpDir, file);
const relPath = `${vp}/${file}`;
const stats = fs.statSync(filePath);
entries.push({
viewport: vp,
fileName: file,
title: file.replace(/^\d+-/, '').replace(/\.png$/, '').replace(/-/g, ' ').toUpperCase(),
relPath,
sizeKb: Math.round(stats.size / 1024)
});
}
}
entries.sort((a, b) => a.fileName.localeCompare(b.fileName));
const htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cognitive Clarity — Playwright Visual Snapshot Gallery</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.gallery-img { cursor: zoom-in; transition: transform 0.2s ease; }
.gallery-img:hover { transform: scale(1.01); }
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<header class="border-b border-slate-800 bg-slate-900/60 backdrop-blur-md sticky top-0 z-40 px-6 py-4">
<div class="max-w-7xl mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-xl font-bold tracking-tight text-white flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-teal-400 animate-pulse"></span>
Cognitive Clarity — Visual Snapshot Gallery
</h1>
<p class="text-xs text-slate-400 mt-0.5">
${entries.length} snapshots across ${viewports.length} viewports (${viewports.join(', ')})
</p>
</div>
<div class="flex flex-wrap gap-2" id="filter-buttons">
<button onclick="filterViewport('all')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-teal-600 text-white" data-vp="all">All</button>
${viewports.map(vp => `<button onclick="filterViewport('${vp}')" class="vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700" data-vp="${vp}">${vp}</button>`).join('')}
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-6 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="gallery-grid">
${entries.map(e => `
<div class="gallery-card bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg flex flex-col" data-viewport="${e.viewport}">
<div class="p-3 border-b border-slate-800 flex justify-between items-center text-xs">
<span class="font-bold text-slate-200">${e.title}</span>
<span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-slate-800 text-teal-400">${e.viewport}</span>
</div>
<div class="bg-black/40 flex-grow flex items-center justify-center p-2">
<img src="${e.relPath}" alt="${e.title}" class="gallery-img max-h-72 w-auto object-contain rounded" onclick="openLightbox('${e.relPath}', '${e.title}')">
</div>
<div class="p-2.5 bg-slate-900/80 border-t border-slate-800 flex justify-between items-center text-[10px] text-slate-500 font-mono">
<span>${e.fileName}</span>
<span>${e.sizeKb} KB</span>
</div>
</div>
`).join('')}
</div>
</main>
<!-- Lightbox Modal -->
<div id="lightbox" class="fixed inset-0 z-50 bg-black/90 backdrop-blur-md hidden flex flex-col items-center justify-center p-4" onclick="closeLightbox()">
<div class="max-w-6xl max-h-[90vh] flex flex-col items-center" onclick="event.stopPropagation()">
<div class="flex justify-between items-center w-full mb-3 text-white">
<h3 id="lightbox-title" class="font-bold text-base"></h3>
<button onclick="closeLightbox()" class="text-slate-400 hover:text-white px-3 py-1 bg-white/10 rounded-lg text-sm">Close ✕</button>
</div>
<img id="lightbox-img" src="" class="max-h-[80vh] max-w-full rounded-xl shadow-2xl border border-white/10 object-contain">
</div>
</div>
<script>
function filterViewport(vp) {
document.querySelectorAll('.vp-btn').forEach(btn => {
if (btn.dataset.vp === vp) {
btn.className = 'vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-teal-600 text-white';
} else {
btn.className = 'vp-btn px-3 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700';
}
});
document.querySelectorAll('.gallery-card').forEach(card => {
if (vp === 'all' || card.dataset.viewport === vp) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
}
});
}
function openLightbox(src, title) {
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox-title').innerText = title;
document.getElementById('lightbox').classList.remove('hidden');
}
function closeLightbox() {
document.getElementById('lightbox').classList.add('hidden');
}
</script>
</body>
</html>`;
fs.writeFileSync(outputHtml, htmlContent, 'utf-8');
console.log(`[Snapshot Gallery] Successfully generated review gallery at ${outputHtml} with ${entries.length} snapshots.`);
+37
View File
@@ -0,0 +1,37 @@
/**
* Headless CBT Session Simulation Helper
*
* Simulates a multi-turn roleplay dialogue with clinical turn-by-turn evaluation
* to test prompt behaviors and scoring rubrics.
*/
import { SCENARIOS, SYSTEM_ANALYSIS_PROMPT } from '../constants';
interface SimulatedTurn {
role: 'user' | 'model';
text: string;
}
export function generateMockDialogue(): SimulatedTurn[] {
const scenario = SCENARIOS[0]; // Workplace Critical Review
return [
{ role: 'model', text: scenario.initialMessage },
{
role: 'user',
text: "I hear that you're concerned about the deployment delay, Marcus. I want to look at the root cause together so we can fix the pipeline rather than rush into another failure."
},
{
role: 'model',
text: "Look, root causes don't satisfy the board. But fine—walk me through why the rollback happened and what you're doing right now to secure the release."
},
{
role: 'user',
text: "The schema migration failed because of a missing index under high load. We've added the migration lock and tested it on staging. I will personally supervise the maintenance window at 8 PM."
}
];
}
console.log('🧪 CBT Simulation Test Harness initialized.');
console.log(`Loaded scenario: "${SCENARIOS[0].title}"`);
console.log('Mock dialogue generated for test runs:');
console.log(JSON.stringify(generateMockDialogue(), null, 2));
+119
View File
@@ -0,0 +1,119 @@
/**
* Scenario Validation Helper Script
*
* Verifies that all built-in and custom CBT scenarios conform to schema rules,
* category invariants, and clinical requirements.
*/
import { SCENARIOS, CBT_GLOSSARY } from '../constants';
import { Scenario } from '../types';
const VALID_CATEGORIES: Scenario['category'][] = ['Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk'];
const VALID_DIFFICULTIES: Scenario['difficulty'][] = ['Beginner', 'Intermediate', 'Advanced'];
interface ValidationResult {
scenarioId: string;
title: string;
isValid: boolean;
errors: string[];
warnings: string[];
}
export function validateScenario(scenario: Scenario): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!scenario.id || typeof scenario.id !== 'string') {
errors.push('Missing or invalid "id"');
}
if (!scenario.title || scenario.title.trim().length === 0) {
errors.push('Missing or empty "title"');
}
if (!scenario.description || scenario.description.trim().length === 0) {
errors.push('Missing or empty "description"');
}
if (!VALID_CATEGORIES.includes(scenario.category)) {
errors.push(`Invalid category: "${scenario.category}". Expected one of: ${VALID_CATEGORIES.join(', ')}`);
}
if (!VALID_DIFFICULTIES.includes(scenario.difficulty)) {
errors.push(`Invalid difficulty: "${scenario.difficulty}". Expected one of: ${VALID_DIFFICULTIES.join(', ')}`);
}
if (!scenario.characterName || scenario.characterName.trim().length === 0) {
errors.push('Missing or empty "characterName"');
}
if (!scenario.initialMessage || scenario.initialMessage.trim().length === 0) {
errors.push('Missing or empty "initialMessage"');
}
if (!Array.isArray(scenario.cbtFocus) || scenario.cbtFocus.length === 0) {
errors.push('cbtFocus must be a non-empty array of strings');
} else {
// Check against glossary terms
const glossaryTerms = CBT_GLOSSARY.map(g => g.term.toLowerCase());
scenario.cbtFocus.forEach(focus => {
const match = glossaryTerms.some(term => term === focus.toLowerCase() || focus.toLowerCase().includes(term));
if (!match) {
warnings.push(`CBT Focus item "${focus}" does not directly match any term in the CBT_GLOSSARY.`);
}
});
}
if (!scenario.systemInstruction || scenario.systemInstruction.trim().length === 0) {
errors.push('Missing or empty "systemInstruction"');
} else {
if (!scenario.systemInstruction.includes('INTERNAL CBT LOGIC') && !scenario.systemInstruction.includes('LOGIC:')) {
warnings.push('systemInstruction does not contain explicit "INTERNAL CBT LOGIC" branching rules.');
}
}
return {
scenarioId: scenario.id,
title: scenario.title,
isValid: errors.length === 0,
errors,
warnings,
};
}
function run() {
console.log('🔍 Validating Cognitive Clarity Scenario Catalog...\n');
let totalErrors = 0;
let totalWarnings = 0;
SCENARIOS.forEach(scenario => {
const result = validateScenario(scenario);
const statusIcon = result.isValid ? '✅' : '❌';
console.log(`${statusIcon} [${scenario.difficulty.toUpperCase()}] ${scenario.title} (${scenario.id})`);
if (result.errors.length > 0) {
console.log(' Errors:');
result.errors.forEach(e => console.log(` - ❌ ${e}`));
totalErrors += result.errors.length;
}
if (result.warnings.length > 0) {
console.log(' Warnings:');
result.warnings.forEach(w => console.log(` - ⚠️ ${w}`));
totalWarnings += result.warnings.length;
}
});
console.log('\n========================================');
console.log(`Summary: ${SCENARIOS.length} scenarios analyzed.`);
console.log(`Errors: ${totalErrors}, Warnings: ${totalWarnings}`);
console.log('========================================\n');
if (totalErrors > 0) {
process.exit(1);
}
}
if (process.argv[1]?.endsWith('validate-scenarios.ts') || process.argv[1]?.endsWith('validate-scenarios.js')) {
run();
}