diff --git a/brainsnn-r3f-app/index.html b/brainsnn-r3f-app/index.html index 26f1041..2e86a6b 100644 --- a/brainsnn-r3f-app/index.html +++ b/brainsnn-r3f-app/index.html @@ -19,14 +19,14 @@ - + - +
diff --git a/brainsnn-r3f-app/public/sw.js b/brainsnn-r3f-app/public/sw.js index 2ce4ed5..be76f0f 100644 --- a/brainsnn-r3f-app/public/sw.js +++ b/brainsnn-r3f-app/public/sw.js @@ -1,77 +1,192 @@ /** - * Layer 91 — BrainSNN Service Worker + * Layer 91 — BrainSNN Service Worker (Beast #11: offline-first + sync queue) * - * Minimal offline shell: cache the app shell on install, network-first - * for API routes, cache-first for hashed asset files. - * - * Versioned cache key — bumping CACHE_VERSION on release invalidates - * old shells without needing a separate cache-busting scheme. + * Strategy: + * - SHELL precache on install (root + index.html + manifest) + * - Hashed /assets/* cache-first with stale-while-revalidate update + * - Workspace chunks fetch + cache lazily on first hit so every + * workspace works offline after one visit + * - API + dynamic share routes network-first, but POST/PUT/DELETE + * get enqueued via Background Sync when offline + * - SPA navigation falls back to cached index.html + * - Skip-waiting + clients.claim so updates take effect on next nav */ -const CACHE_VERSION = 'brainsnn-v1'; -const SHELL_URLS = ['/', '/index.html']; +const CACHE_VERSION = 'brainsnn-v2'; +const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest']; +const QUEUE_TAG = 'brainsnn-write-queue'; +const QUEUE_DB = 'brainsnn-sw'; +const QUEUE_STORE = 'queue'; +// ---------- precache ---------- self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_VERSION).then((cache) => cache.addAll(SHELL_URLS)).catch(() => {}), + caches.open(CACHE_VERSION) + .then((c) => c.addAll(PRECACHE_URLS)) + .catch(() => { /* offline first-install — fine */ }) + .then(() => self.skipWaiting()) ); - self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))), - ).then(() => self.clients.claim()), + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) ); }); +// ---------- helpers ---------- function isApi(url) { - return url.pathname.startsWith('/api/') || url.pathname.startsWith('/r/') || - url.pathname.startsWith('/i/') || url.pathname.startsWith('/q/') || - url.pathname.startsWith('/a/') || url.pathname.startsWith('/d/') || - url.pathname.startsWith('/x/') || url.pathname.startsWith('/t/') || - url.pathname.startsWith('/n/') || url.pathname.startsWith('/v/') || - url.pathname.startsWith('/w/') || url.pathname.startsWith('/b/'); + return url.pathname.startsWith('/api/') || + /^\/[rqidaxntvwb]\//.test(url.pathname); +} + +function isAsset(url) { + return url.pathname.startsWith('/assets/'); +} + +async function staleWhileRevalidate(request) { + const cache = await caches.open(CACHE_VERSION); + const hit = await cache.match(request); + const fetchPromise = fetch(request).then((resp) => { + if (resp && resp.status === 200) cache.put(request, resp.clone()).catch(() => {}); + return resp; + }).catch(() => hit); + return hit || fetchPromise; +} + +// ---------- IDB for background sync queue ---------- +function openQueueDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(QUEUE_DB, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(QUEUE_STORE)) { + db.createObjectStore(QUEUE_STORE, { keyPath: 'id', autoIncrement: true }); + } + }; + req.onsuccess = () => { + req.result.onversionchange = () => req.result.close(); + resolve(req.result); + }; + req.onerror = () => reject(req.error); + }); +} + +async function enqueueRequest(request) { + try { + const db = await openQueueDb(); + const body = await request.clone().text(); + const entry = { + url: request.url, + method: request.method, + headers: Array.from(request.headers.entries()), + body, + ts: Date.now() + }; + await new Promise((resolve, reject) => { + const tx = db.transaction(QUEUE_STORE, 'readwrite'); + const req = tx.objectStore(QUEUE_STORE).add(entry); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + if ('sync' in self.registration) { + try { await self.registration.sync.register(QUEUE_TAG); } catch { /* noop */ } + } + } catch { /* swallow — degrade gracefully */ } +} + +async function replayQueue() { + const db = await openQueueDb(); + const entries = await new Promise((resolve, reject) => { + const tx = db.transaction(QUEUE_STORE, 'readonly'); + const req = tx.objectStore(QUEUE_STORE).getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + + for (const entry of entries) { + try { + const headers = new Headers(entry.headers); + const resp = await fetch(entry.url, { method: entry.method, headers, body: entry.body }); + if (resp.ok) { + await new Promise((resolve, reject) => { + const tx = db.transaction(QUEUE_STORE, 'readwrite'); + const req = tx.objectStore(QUEUE_STORE).delete(entry.id); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } + } catch { + // Still offline / server unreachable — keep entry for next sync. + break; + } + } } +self.addEventListener('sync', (event) => { + if (event.tag === QUEUE_TAG) event.waitUntil(replayQueue()); +}); + +// ---------- fetch routing ---------- self.addEventListener('fetch', (event) => { - if (event.request.method !== 'GET') return; - const url = new URL(event.request.url); + const { request } = event; + const url = new URL(request.url); if (url.origin !== self.location.origin) return; - // API / dynamic routes — network first, no cache (we want fresh - // scores and OG images). - if (isApi(url)) { - event.respondWith(fetch(event.request).catch(() => new Response('offline', { status: 503 }))); + // Non-GET to known API routes → queue if offline. + if (request.method !== 'GET' && isApi(url)) { + event.respondWith( + fetch(request.clone()).catch(async () => { + await enqueueRequest(request); + return new Response(JSON.stringify({ queued: true }), { + status: 202, + headers: { 'Content-Type': 'application/json' } + }); + }) + ); return; } - // Hashed assets → cache first - if (url.pathname.startsWith('/assets/')) { + if (request.method !== 'GET') return; + + // API reads — network first; cache the last response so /a/ + // share cards still render offline. + if (isApi(url)) { event.respondWith( - caches.match(event.request).then((hit) => { - if (hit) return hit; - return fetch(event.request).then((resp) => { - if (!resp || resp.status !== 200) return resp; + fetch(request).then((resp) => { + if (resp && resp.status === 200) { const clone = resp.clone(); - caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone)).catch(() => {}); - return resp; - }); - }), + caches.open(CACHE_VERSION).then((c) => c.put(request, clone)).catch(() => {}); + } + return resp; + }).catch(() => caches.match(request).then((hit) => hit || new Response('offline', { status: 503 }))) ); return; } - // SPA shell — network first, fall back to cached index.html for - // offline navigations. + // Hashed assets → stale-while-revalidate so updated chunks land + // on the next reload without a hard refresh. + if (isAsset(url)) { + event.respondWith(staleWhileRevalidate(request)); + return; + } + + // SPA navigation — network first, cached index.html offline fallback. event.respondWith( - fetch(event.request).then((resp) => { + fetch(request).then((resp) => { if (resp && resp.status === 200 && url.pathname === '/') { const clone = resp.clone(); caches.open(CACHE_VERSION).then((c) => c.put('/', clone)).catch(() => {}); } return resp; - }).catch(() => caches.match('/index.html').then((hit) => hit || new Response('offline', { status: 503 }))), + }).catch(() => caches.match('/index.html').then((hit) => hit || new Response('offline', { status: 503 }))) ); }); + +// ---------- messaging (main thread → SW) ---------- +self.addEventListener('message', (event) => { + if (event.data?.type === 'skipWaiting') self.skipWaiting(); + if (event.data?.type === 'replayNow') event.waitUntil(replayQueue()); +}); diff --git a/brainsnn-r3f-app/src/App.jsx b/brainsnn-r3f-app/src/App.jsx deleted file mode 100644 index adc144d..0000000 --- a/brainsnn-r3f-app/src/App.jsx +++ /dev/null @@ -1,1026 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import BrainScene from './components/BrainScene'; -import ControlsBar from './components/ControlsBar'; -import InspectorPanel from './components/InspectorPanel'; -import ActivityCharts from './components/ActivityCharts'; -import EEGPanel from './components/EEGPanel'; -import TimelinePanel from './components/TimelinePanel'; -import ExportPanel from './components/ExportPanel'; -import TribePanel from './components/TribePanel'; -import CognitiveFirewallPanel from './components/CognitiveFirewallPanel'; -import GemmaAnalysisPanel from './components/GemmaAnalysisPanel'; -import GeminiAnalysisPanel from './components/GeminiAnalysisPanel'; -import LobsterTrapPanel from './components/LobsterTrapPanel'; -import SnapshotPanel from './components/SnapshotPanel'; -import AnalyticsDashboard from './components/AnalyticsDashboard'; -import NarrativePanel from './components/NarrativePanel'; -import ToastContainer from './components/ToastContainer'; -import KeyboardHelp from './components/KeyboardHelp'; -import SharePanel from './components/SharePanel'; -import OnboardingWalkthrough from './components/OnboardingWalkthrough'; -import SplitBrainView from './components/SplitBrainView'; -import VoiceControl from './components/VoiceControl'; -import PluginPanel from './components/PluginPanel'; -import LiveSyncPanel from './components/LiveSyncPanel'; -import HeatmapTimeline from './components/HeatmapTimeline'; -import KnowledgeBrainPanel from './components/KnowledgeBrainPanel'; -import MCPBridgePanel from './components/MCPBridgePanel'; -import CodeBrainPanel from './components/CodeBrainPanel'; -import BrainStewardPanel from './components/BrainStewardPanel'; -import ConversationBrainPanel from './components/ConversationBrainPanel'; -import ImmunityPanel from './components/ImmunityPanel'; -import EmbeddingsPanel from './components/EmbeddingsPanel'; -import RedTeamPanel from './components/RedTeamPanel'; -import QuizPanel from './components/QuizPanel'; -import DailyChallengePanel from './components/DailyChallengePanel'; -import BypassSubmitPanel from './components/BypassSubmitPanel'; -import AutopsyPanel from './components/AutopsyPanel'; -import TimeSeriesPanel from './components/TimeSeriesPanel'; -import InboxPanel from './components/InboxPanel'; -import DiffPanel from './components/DiffPanel'; -import ScanAnywherePanel from './components/ScanAnywherePanel'; -import WeeklyRecapPanel from './components/WeeklyRecapPanel'; -import FingerprintPanel from './components/FingerprintPanel'; -import EchoPanel from './components/EchoPanel'; -import ApiDocsPanel from './components/ApiDocsPanel'; -import CustomRulesPanel from './components/CustomRulesPanel'; -import BadgesPanel from './components/BadgesPanel'; -import PortabilityPanel from './components/PortabilityPanel'; -import OcrPanel from './components/OcrPanel'; -import AudioPanel from './components/AudioPanel'; -import MacrosPanel from './components/MacrosPanel'; -import DiagnosticPanel from './components/DiagnosticPanel'; -import HypothesisPanel from './components/HypothesisPanel'; -import ContextMemoryPanel from './components/ContextMemoryPanel'; -import DebatePanel from './components/DebatePanel'; -import ReplayPanel from './components/ReplayPanel'; -import CoveragePanel from './components/CoveragePanel'; -import CalendarHeatmapPanel from './components/CalendarHeatmapPanel'; -import ToneShifterPanel from './components/ToneShifterPanel'; -import SimilaritySearchPanel from './components/SimilaritySearchPanel'; -import OscillationsPanel from './components/OscillationsPanel'; -import LayerExplorerPanel from './components/LayerExplorerPanel'; -import TextAdventurePanel from './components/TextAdventurePanel'; -import ComparatorPanel from './components/ComparatorPanel'; -import DrillDownPanel from './components/DrillDownPanel'; -import SessionRoomsPanel from './components/SessionRoomsPanel'; -import ComplimentPanel from './components/ComplimentPanel'; -import ExtensionPanel from './components/ExtensionPanel'; -import RulePacksPanel from './components/RulePacksPanel'; -import ScanArchivePanel from './components/ScanArchivePanel'; -import JournalismPanel from './components/JournalismPanel'; -import PrivacyBudgetPanel from './components/PrivacyBudgetPanel'; -import GenrePanel from './components/GenrePanel'; -import PersonaPanel from './components/PersonaPanel'; -import ComposerPanel from './components/ComposerPanel'; -import PersonalDictionaryPanel from './components/PersonalDictionaryPanel'; -import PwaInstallPanel from './components/PwaInstallPanel'; -import CommandPalette from './components/CommandPalette'; -import FeedbackPanel from './components/FeedbackPanel'; -import RoleTourPanel from './components/RoleTourPanel'; -import SyncPanel from './components/SyncPanel'; -import HotkeyMap from './components/HotkeyMap'; -import ThemePanel from './components/ThemePanel'; -import CommunityPackPanel from './components/CommunityPackPanel'; -import MilestonePanel from './components/MilestonePanel'; -import { registerServiceWorker } from './utils/pwa'; -import { registerTheme } from './utils/theme'; -import DreamModePanel from './components/DreamModePanel'; -import AdversarialTrainingPanel from './components/AdversarialTrainingPanel'; -import NeuroRagPanel from './components/NeuroRagPanel'; -import MultimodalRagPanel from './components/MultimodalRagPanel'; -import VectorGraphFusionPanel from './components/VectorGraphFusionPanel'; -import DirectInsertPanel from './components/DirectInsertPanel'; -import AffectiveDecoderPanel from './components/AffectiveDecoderPanel'; -import NeurochemistryPanel from './components/NeurochemistryPanel'; -import BrainEvolvePanel from './components/BrainEvolvePanel'; -import AttackEvolvePanel from './components/AttackEvolvePanel'; -import ErrorBoundary from './components/ErrorBoundary'; -import { decodeStateFromHash } from './components/SharePanel'; -import DemoTiles from './components/DemoTiles'; -import { decodeReaction } from './utils/reactionCard'; -import { REGION_INFO } from './data/network'; -import { mapTRIBEToRegions, setActiveRules, resetActiveRules, deserializeRules } from './utils/cognitiveFirewall'; -import { registerBridgeContext, logToolCall } from './utils/mcpBridge'; -import { recordEvent as recordImmunity, IMMUNITY_EVENTS } from './utils/immunityScore'; -import { applyScenario, createInitialState, resetState, simulateStep } from './utils/sim'; -import { applyMockEEG, connectMuseEEG, connectSerialEEG, mapEEGToRegions, parseMusePacket } from './utils/eeg'; -import { startCanvasRecording } from './utils/recording'; -import { listSnapshots, loadSnapshot, saveSnapshot } from './utils/snapshots'; -import { registerDreamProviders, startDreamMonitor, stopDreamMonitor, markActivity } from './utils/dreamMode'; -import { mapRagToRegions } from './utils/neuroRag'; -import { mapMultimodalToRegions } from './utils/multimodalRag'; -import { mapFusedToRegions } from './utils/vectorGraphFusion'; -import { applyAffectsToBrainState } from './utils/affectiveDecoder'; -import { applyNTBath } from './utils/neurochemistry'; -import { registerShortcuts } from './utils/shortcuts'; -import { trendDirection } from './utils/analytics'; -import { toastSuccess, toastInfo, toastWarning } from './utils/toastStore'; - -export default function App() { - const [state, setState] = useState(() => { - // Check for shared state in URL hash - const shared = decodeStateFromHash(); - if (shared) { - const initial = createInitialState(); - return { - ...initial, - regions: { ...initial.regions, ...shared.regions }, - weights: { ...initial.weights, ...(shared.weights || {}) }, - scenario: shared.scenario || initial.scenario, - selected: shared.selected || initial.selected, - tick: shared.tick || 0 - }; - } - return createInitialState(); - }); - const [mode, setMode] = useState('simulation'); - const [eegStatus, setEegStatus] = useState({ connected: false, label: 'No device connected' }); - const [timelineIndex, setTimelineIndex] = useState(0); - const [isRecording, setIsRecording] = useState(false); - const [exportStatus, setExportStatus] = useState('idle'); - const [exportProgress, setExportProgress] = useState(0); - const [quality, setQuality] = useState('high'); - const [gifOptions, setGifOptions] = useState({ trimStart: 0, trimDuration: 2.5, fps: 12, width: 720 }); - const [showKbHelp, setShowKbHelp] = useState(false); - const [firewallResult, setFirewallResult] = useState(null); - const [incomingImmunityCard] = useState(() => { - try { - const params = new URLSearchParams(window.location.search); - return params.get('i') || null; - } catch { - return null; - } - }); - const [incomingAutopsyHash] = useState(() => { - try { - const params = new URLSearchParams(window.location.search); - return params.get('a') || null; - } catch { - return null; - } - }); - const [incomingDailyHash] = useState(() => { - try { - const params = new URLSearchParams(window.location.search); - return params.get('d') || null; - } catch { - return null; - } - }); - const [initialFirewallScan, setInitialFirewallScan] = useState(() => { - try { - const params = new URLSearchParams(window.location.search); - const hash = params.get('r'); - if (!hash) return null; - const reaction = decodeReaction(hash); - if (!reaction || !reaction.text) return null; - const score = { - emotionalActivation: reaction.emotionalActivation, - cognitiveSuppression: reaction.cognitiveSuppression, - manipulationPressure: reaction.manipulationPressure, - trustErosion: reaction.trustErosion, - evidence: [], - confidence: 'shared', - recommendedAction: 'Rehydrated from shared reaction link.', - source: 'shared-link' - }; - return { text: reaction.text, result: score, autoApply: true }; - } catch { - return null; - } - }); - const [knowledgeMode, setKnowledgeMode] = useState(false); - const [affectOverride, setAffectOverride] = useState(null); - const [lastAffectDecode, setLastAffectDecode] = useState(null); - const recorderRef = useRef(null); - const historyRef = useRef([]); - const stateRef = useRef(state); - stateRef.current = state; - - // Track per-region history for analytics trends - useEffect(() => { - historyRef.current.push({ regions: { ...state.regions } }); - if (historyRef.current.length > 60) historyRef.current.shift(); - }, [state.tick]); - - // Layer 91 — register service worker once on mount - // Layer 98 — apply theme + a11y prefs - useEffect(() => { registerServiceWorker(); registerTheme(); }, []); - - // Layer 19 — register MCP bridge context once - useEffect(() => { - registerBridgeContext({ - getState: () => stateRef.current, - setState, - getHistory: () => historyRef.current, - applyScenarioKey: (key) => setState((s) => applyScenario(s, key)), - triggerBurst: () => setState((s) => ({ ...s, burst: 20, scenario: 'MCP Sensory Burst' })), - resetBrain: () => setState(resetState()), - onToolCall: (entry) => logToolCall(entry) - }); - }, []); - - // Layer 26 — register dream providers + start monitor - useEffect(() => { - registerDreamProviders({ - getSnapshots: () => - listSnapshots() - .slice(0, 10) - .map((s) => loadSnapshot(s.id)) - .filter(Boolean), - setState, - narrate: (text) => toastInfo(text) - }); - startDreamMonitor(); - return () => stopDreamMonitor(); - }, []); - - const trends = useMemo(() => { - const t = {}; - for (const key of Object.keys(state.regions)) { - const values = historyRef.current.map((h) => h.regions?.[key] ?? 0); - t[key] = trendDirection(values); - } - return t; - }, [state.tick, state.regions]); - - // Keyboard shortcuts - useEffect(() => { - const QUALITY_CYCLE = ['low', 'high', 'ultra']; - return registerShortcuts({ - toggleRun: () => setState((s) => ({ ...s, running: !s.running })), - burst: () => { setState((s) => ({ ...s, burst: 20, scenario: 'Sensory Burst' })); toastInfo('Sensory burst triggered'); }, - reset: () => { setState(resetState()); setMode('simulation'); toastInfo('Brain state reset'); }, - modeSimulation: () => { setMode('simulation'); toastInfo('Switched to Simulation mode'); }, - modeTribe: () => { setMode('tribe'); toastInfo('Switched to TRIBE v2 mode'); }, - modeEeg: () => { setMode('eeg'); toastInfo('Switched to EEG mode'); }, - snapshot: () => { saveSnapshot(state); toastSuccess('Snapshot saved'); }, - record: () => { - const canvas = document.querySelector('canvas'); - if (!canvas) return; - if (!isRecording) { - try { - recorderRef.current = startCanvasRecording(canvas, { onStatus: setExportStatus, onProgress: setExportProgress }); - setIsRecording(true); - toastInfo('Recording started'); - } catch (err) { setExportStatus(err.message); } - } else if (recorderRef.current) { - recorderRef.current.stop(); - recorderRef.current = null; - setIsRecording(false); - setExportStatus('WebM ready'); - setExportProgress(100); - toastSuccess('Recording saved'); - } - }, - cycleQuality: () => { - setQuality((prev) => { - const idx = QUALITY_CYCLE.indexOf(prev); - const next = QUALITY_CYCLE[(idx + 1) % QUALITY_CYCLE.length]; - toastInfo(`Quality: ${next}`); - return next; - }); - }, - showHelp: () => setShowKbHelp(true) - }); - }, [state, isRecording]); - - // Simulation loop — only active in simulation mode - useEffect(() => { - if (mode !== 'simulation') return; - const id = setInterval(() => { - setState((prev) => simulateStep(prev)); - }, 180); - return () => clearInterval(id); - }, [mode]); - - useEffect(() => { - setTimelineIndex(Math.max(0, state.history.length - 1)); - }, [state.history.length]); - - const stats = useMemo(() => ({ - mean: state.mean ?? Object.values(state.regions).reduce((a, v) => a + v, 0) / Object.keys(state.regions).length, - plasticity: state.plasticity ?? Object.values(state.weights).reduce((a, v) => a + v, 0) / Object.keys(state.weights).length - }), [state]); - - const timelineFrame = state.history[timelineIndex] ?? state.history[state.history.length - 1]; - - // Apply a TRIBE v2 prediction frame to state - const applyTribeFrame = useCallback((frame) => { - if (!frame?.regions) return; - setState((prev) => { - const regions = { ...prev.regions, ...frame.regions }; - const mean = Object.values(regions).reduce((a, v) => a + v, 0) / Object.keys(regions).length; - const plasticity = Object.values(prev.weights).reduce((a, v) => a + v, 0) / Object.keys(prev.weights).length; - return { - ...prev, - regions, - tick: prev.tick + 1, - scenario: frame.scenario || 'TRIBE v2', - history: [...prev.history.slice(-39), { mean, plasticity }], - mean, - plasticity - }; - }); - }, []); - - const modeLabel = mode === 'tribe' ? 'TRIBE v2' : mode === 'eeg' ? 'Live EEG' : 'Simulation'; - - return ( -
-
- - {/* Global overlays */} - - setShowKbHelp(false)} /> - - - - -
-
- { markActivity(); setState((s) => ({ ...s, running: !s.running })); }} - onBurst={() => { markActivity(); setState((s) => ({ ...s, burst: 20, scenario: 'Sensory Burst' })); }} - onReset={() => { markActivity(); setState(resetState()); setMode('simulation'); }} - onScenario={(key) => { markActivity(); setState((s) => applyScenario(s, key)); setMode('simulation'); }} - onToggleRecording={() => { - const canvas = document.querySelector('canvas'); - if (!canvas) return; - if (!isRecording) { - try { - recorderRef.current = startCanvasRecording(canvas, { - onStatus: setExportStatus, - onProgress: setExportProgress - }); - setIsRecording(true); - } catch (err) { - setExportStatus(err.message); - } - } else if (recorderRef.current) { - recorderRef.current.stop(); - recorderRef.current = null; - setIsRecording(false); - setExportStatus('WebM ready'); - setExportProgress(100); - } - }} - onExportGif={async () => { - const canvas = document.querySelector('canvas'); - if (!canvas) return; - try { - if (!recorderRef.current) { - recorderRef.current = startCanvasRecording(canvas, { - onStatus: setExportStatus, - onProgress: setExportProgress - }); - setIsRecording(true); - setTimeout(async () => { - if (recorderRef.current) { - await recorderRef.current.convertToGif(gifOptions); - recorderRef.current = null; - setIsRecording(false); - } - }, Math.max(1200, gifOptions.trimDuration * 1000)); - } else { - await recorderRef.current.convertToGif(gifOptions); - recorderRef.current = null; - setIsRecording(false); - } - } catch (err) { - setExportStatus(err.message); - } - }} - /> - - { - setFirewallResult(result); - setState((s) => mapTRIBEToRegions(s, result)); - setInitialFirewallScan({ text, result, autoApply: false }); - recordImmunity(IMMUNITY_EVENTS.FIREWALL_SCAN, { - pressure: result.manipulationPressure, - confidence: result.confidence - }); - toastInfo('Demo scan applied — watch the brain react'); - }} - /> - -
-
- Tick {state.tick} - Mean firing {stats.mean.toFixed(3)} - Plasticity {stats.plasticity.toFixed(3)} - Selected {state.selected} - Quality {quality} - Mode {modeLabel} -
- -
- setState((s) => ({ ...s, selected: id || s.selected }))} - /> -
-
- -
-
-
Regions7
-
Connections10
-
Scenario{state.scenario}
-
Lead region{Object.entries(state.regions).sort((a, b) => b[1] - a[1])[0][0]}
-
- -
-
Selected region
-

{state.selected} · {REGION_INFO[state.selected].name}

-

{REGION_INFO[state.selected].role}

-

- Data source: {modeLabel}. Switch modes to toggle between synthetic simulation, TRIBE v2 neural predictions, and live EEG input. - Press ? for keyboard shortcuts. -

-
-
- - - - - - - - - - - - - - - - { - setFirewallResult(result); - setState((s) => mapTRIBEToRegions(s, result)); - recordImmunity(IMMUNITY_EVENTS.FIREWALL_SCAN, { - pressure: result.manipulationPressure, - confidence: result.confidence - }); - toastWarning(`Firewall: ${result.recommendedAction.slice(0, 60)}...`); - }} - /> - - - { - setFirewallResult(gemmaResult); - setState((s) => mapTRIBEToRegions(s, gemmaResult)); - recordImmunity(IMMUNITY_EVENTS.GEMMA_SCAN, { - pressure: gemmaResult.manipulationPressure - }); - toastInfo('Gemma 4 analysis applied to brain'); - }} - /> - - - - { - setFirewallResult(geminiResult); - setState((s) => mapTRIBEToRegions(s, geminiResult)); - recordImmunity(IMMUNITY_EVENTS.GEMMA_SCAN, { - pressure: geminiResult.manipulationPressure - }); - toastInfo('Gemini analysis applied to brain'); - }} - /> - - - - - - - { - setState((prev) => ({ - ...prev, - regions: { ...prev.regions, ...snap.regions }, - weights: { ...prev.weights, ...snap.weights }, - selected: snap.selected || prev.selected, - scenario: `Restored: ${snap.name}`, - tick: prev.tick + 1 - })); - recordImmunity(IMMUNITY_EVENTS.SNAPSHOT_SAVED, { name: snap.name }); - toastSuccess(`Restored: ${snap.name}`); - }} - /> - - - - - { - setState((prev) => ({ - ...prev, - regions: { ...prev.regions, ...kbState.regions }, - weights: { ...prev.weights, ...kbState.weights }, - scenario: kbState.scenario || 'Knowledge Brain', - tick: prev.tick + 1 - })); - setKnowledgeMode(true); - recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, {}); - toastSuccess('Knowledge map applied — 3D brain now shows knowledge domains'); - }} - /> - - - - - - - - { - setState((prev) => ({ - ...prev, - regions: { ...prev.regions, ...payload.regions }, - scenario: payload.scenario, - tick: prev.tick + 1 - })); - recordImmunity(IMMUNITY_EVENTS.CODE_ANALYZED, {}); - toastSuccess('Code communities mapped onto brain'); - }} - /> - - - - - - - - { - setState((prev) => ({ - ...prev, - regions: { ...prev.regions, ...payload.regions }, - scenario: payload.scenario, - tick: prev.tick + 1 - })); - recordImmunity(IMMUNITY_EVENTS.CONVERSATION_ANALYZED, { - pressureAvg: payload.pressureAvg, - turns: payload.turns - }); - toastSuccess('Conversation final state applied to brain'); - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - markActivity(); - setState((prev) => mapRagToRegions(prev, ragResult)); - recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { - name: `RAG: ${ragResult.results.length} hits · ${ragResult.mode}` - }); - toastSuccess(`Retrieved ${ragResult.results.length} chunks · ${ragResult.mode}`); - }} - /> - - - - { - markActivity(); - setState((prev) => mapMultimodalToRegions(prev, mmResult)); - const histLabel = Object.entries(mmResult.byModality || {}) - .map(([k, v]) => `${k}:${v}`).join(' '); - recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { - name: `Multimodal RAG: ${mmResult.results.length} hits · ${histLabel}` - }); - toastSuccess(`Retrieved ${mmResult.results.length} items · ${mmResult.mode}`); - }} - /> - - - - { - markActivity(); - setState((prev) => mapFusedToRegions(prev, fusedResult)); - const stats = fusedResult.fusionStats || {}; - recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { - name: `Fused RAG: ${fusedResult.results.length} hits · ${stats.siblingPulls || 0} siblings` - }); - toastSuccess(`Fused ${fusedResult.results.length} items · ${fusedResult.mode}`); - }} - /> - - - - - - - - { - markActivity(); - setAffectOverride(map); - if (map) { - toastSuccess(`Affect colors applied to ${Object.keys(map).length} regions`); - } else { - toastInfo('Affect colors cleared'); - } - }} - onApplyActivation={(decoded) => { - markActivity(); - setLastAffectDecode(decoded); - setState((prev) => applyAffectsToBrainState(prev, decoded)); - recordImmunity(IMMUNITY_EVENTS.AFFECT_DECODED, { - name: `Affects: ${decoded.dominant.map((d) => d.label).join(', ') || 'neutral'}` - }); - toastInfo('Affect activation nudged into brain state'); - }} - onDecode={(decoded) => setLastAffectDecode(decoded)} - /> - - - - { - markActivity(); - setState((prev) => applyNTBath(prev, levels, opts)); - toastSuccess(`Applied ${opts?.label ?? 'NT bath'} to brain`); - }} - /> - - - - { - markActivity(); - if (!node?.ruleSet) { - resetActiveRules(); - toastInfo('Firewall reset to default rules'); - return; - } - const rules = deserializeRules(node.ruleSet); - setActiveRules(rules); - const f1 = node.results?.summary?.thresholds?.[0.3]?.f1 ?? 0; - recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { - name: `Evolve promoted: F1 ${f1.toFixed(3)}` - }); - toastSuccess(`Evolved firewall promoted (F1 ${f1.toFixed(3)})`); - }} - /> - - - - { - markActivity(); - if (!node) { - toastInfo('Red team corpus reset to defaults'); - return; - } - const evasion = ((1 - (node.detection || 0)) * 100).toFixed(0); - recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { - name: `Attack promoted (${category}): ${evasion}% evasion` - }); - toastSuccess(`Evolved attack added to "${category}" — ${evasion}% evasion`); - }} - /> - - - - - - - - - { - // Map plugin scores to firewall-compatible format if available - const mapped = { - emotionalActivation: combined.negativity ?? combined.emotionalActivation ?? 0, - cognitiveSuppression: combined.complexity ?? combined.cognitiveSuppression ?? 0, - manipulationPressure: 1 - (combined.credibility ?? 0.5), - trustErosion: combined.negativity ?? combined.trustErosion ?? 0 - }; - setState((s) => mapTRIBEToRegions(s, mapped)); - toastInfo('Plugin analysis applied to brain'); - }} - /> - - { - if (remote?.regions) { - setState((prev) => ({ - ...prev, - regions: { ...prev.regions, ...remote.regions }, - scenario: remote.scenario || 'Remote Sync', - tick: prev.tick + 1 - })); - } - }} - /> - - - - setGifOptions((prev) => ({ ...prev, [key]: value }))} - /> - - setState((s) => ({ ...s, burst: 20, tick: 0, scenario: 'Replay Burst' }))} - /> - - { - try { - const result = await connectMuseEEG(); - setEegStatus({ connected: true, label: `Connected via Bluetooth: ${result.deviceName}` }); - setMode('eeg'); - const mockPacket = new DataView(new Uint8Array([1, 20, 0, 120, 0, 90, 0, 60]).buffer); - const parsed = parseMusePacket(mockPacket); - setState((s) => mapEEGToRegions(s, parsed)); - toastSuccess('Muse EEG connected'); - } catch (err) { - setEegStatus({ connected: false, label: err.message }); - toastWarning('EEG connection failed'); - } - }} - onConnectSerial={async () => { - try { - await connectSerialEEG(); - setEegStatus({ connected: true, label: 'Serial EEG bridge connected' }); - setMode('eeg'); - toastSuccess('Serial EEG connected'); - } catch (err) { - setEegStatus({ connected: false, label: err.message }); - } - }} - onInjectMock={() => { - setState((s) => applyMockEEG(s)); - setEegStatus({ connected: true, label: 'Mock EEG injected into THL/PFC/HPC' }); - setMode('eeg'); - toastInfo('Mock EEG data injected'); - }} - /> - -
-
Deployment
-

Ready for GitHub Pages and Vercel

-

This project includes deployment configs plus browser-side export helpers for shareable demos.

-
-
- - setState((s) => ({ ...s, selected: id }))} - timelineFrame={timelineFrame} - /> -
-
- ); -} diff --git a/brainsnn-r3f-app/src/components/ActivityCharts.jsx b/brainsnn-r3f-app/src/components/ActivityCharts.jsx index 1c83257..51c5a4c 100644 --- a/brainsnn-r3f-app/src/components/ActivityCharts.jsx +++ b/brainsnn-r3f-app/src/components/ActivityCharts.jsx @@ -18,13 +18,13 @@ export default function ActivityCharts({ state }) {

Region activity

Live values
- +
{Object.keys(state.regions).map((k) => {k})}

Pathway plasticity

Current weights
- +
{Object.keys(state.weights).map((k) => {k})}
diff --git a/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx b/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx index 60652fe..d3f00f1 100644 --- a/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx +++ b/brainsnn-r3f-app/src/components/AdversarialTrainingPanel.jsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; import { getLearnedPatterns, saveLearnedPatterns, clearLearnedPatterns, - trainFromRedTeam, evaluateWithLearned + trainFromRedTeamAsync, evaluateWithLearned } from '../utils/adversarialTraining'; -import { runRedTeam } from '../utils/redTeam'; +import { runRedTeamAsync } from '../utils/redTeam'; import { recordEvent as recordImmunity, IMMUNITY_EVENTS } from '../utils/immunityScore'; /** @@ -21,9 +21,10 @@ export default function AdversarialTrainingPanel() { async function handleTrain() { setTraining(true); setResult(null); - await new Promise((r) => setTimeout(r, 20)); // let UI update - const baseline = runRedTeam(); - const { learned: newLearned, stats } = trainFromRedTeam(baseline); + // Both red team + n-gram mining run in the firewall worker so + // the brain keeps rendering during the training pass. + const baseline = await runRedTeamAsync(); + const { learned: newLearned, stats } = await trainFromRedTeamAsync(baseline); saveLearnedPatterns(newLearned); setLearned(newLearned); const after = evaluateWithLearned(); diff --git a/brainsnn-r3f-app/src/components/AnalyticsDashboard.jsx b/brainsnn-r3f-app/src/components/AnalyticsDashboard.jsx index 1a8935c..e1c3200 100644 --- a/brainsnn-r3f-app/src/components/AnalyticsDashboard.jsx +++ b/brainsnn-r3f-app/src/components/AnalyticsDashboard.jsx @@ -7,7 +7,7 @@ import { REGION_INFO } from '../data/network'; // ---------- Mini sparkline (canvas) ---------- -function Sparkline({ data, width = 120, height = 32, color = '#4fa8b3' }) { +function Sparkline({ data, width = 120, height = 32, color = 'var(--accent)' }) { const ref = useRef(null); useEffect(() => { const canvas = ref.current; @@ -118,7 +118,7 @@ export default function AnalyticsDashboard({ state }) { {key} - +
{((state.regions[key] || 0) * 100).toFixed(0)}% diff --git a/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx b/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx index 1d3eab1..c736372 100644 --- a/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx +++ b/brainsnn-r3f-app/src/components/ApiDocsPanel.jsx @@ -138,7 +138,7 @@ export default function ApiDocsPanel() { }} > {streamEvents.map((ev, i) => ( -
+
{ev.event}{' '} {JSON.stringify(ev.data)}
@@ -146,7 +146,7 @@ export default function ApiDocsPanel() {
)} - {err &&

Error: {err}

} + {err &&

Error: {err}

} {result && (
= 65 ? '#dd6974' : pct >= 35 ? '#fdab43' : '#6daa45'; + const tone = pct >= 65 ? 'var(--danger)' : pct >= 35 ? 'var(--severity-mid)' : 'var(--ok)'; return (
@@ -113,7 +113,7 @@ export default function AudioPanel() { )}
- {err &&

Error: {err}

} + {err &&

Error: {err}

} {(finals || interim) && (
diff --git a/brainsnn-r3f-app/src/components/AutopsyPanel.jsx b/brainsnn-r3f-app/src/components/AutopsyPanel.jsx index 3edd9a5..68107cf 100644 --- a/brainsnn-r3f-app/src/components/AutopsyPanel.jsx +++ b/brainsnn-r3f-app/src/components/AutopsyPanel.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { bus } from '../shell/bus'; import { runAutopsy, summarizeAutopsy, AUTOPSY_EXAMPLE } from '../utils/autopsy'; import { buildAutopsyPayload, autopsyUrl, autopsyLevelFor, decodeAutopsy @@ -21,6 +22,11 @@ export default function AutopsyPanel({ initialHash = null }) { const [recorded, setRecorded] = useState(false); const [incoming] = useState(() => initialHash ? decodeAutopsy(initialHash) : null); + // AppShell composer 'autopsy' mode pipes transcript text in. + useEffect(() => bus.on('shell:compose', ({ text, mode }) => { + if (mode === 'autopsy' && text) setRaw(text); + }), []); + const autopsy = useMemo(() => raw.trim() ? runAutopsy(raw) : null, [raw]); const summary = useMemo(() => autopsy ? summarizeAutopsy(autopsy) : null, [autopsy]); const level = summary ? autopsyLevelFor(summary.pressure) : null; @@ -141,7 +147,7 @@ export default function AutopsyPanel({ initialHash = null }) {
{fetchError && ( -

+

Fetch failed: {fetchError}.

)} diff --git a/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx b/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx index a5991df..24407f0 100644 --- a/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx +++ b/brainsnn-r3f-app/src/components/BrainEvolvePanel.jsx @@ -1,8 +1,6 @@ import React, { useMemo, useRef, useState } from 'react'; -import { - runEvolution, - seedNode -} from '../utils/evolve/loop'; +import { seedNode } from '../utils/evolve/loop'; +import { runEvolutionAsync as runEvolution } from '../utils/evolve/runInWorker'; import { SAMPLER_KEYS, SAMPLER_LABELS, diff --git a/brainsnn-r3f-app/src/components/BrainScene.jsx b/brainsnn-r3f-app/src/components/BrainScene.jsx index 688f39b..a1bb438 100644 --- a/brainsnn-r3f-app/src/components/BrainScene.jsx +++ b/brainsnn-r3f-app/src/components/BrainScene.jsx @@ -8,6 +8,7 @@ import { KNOWLEDGE_DOMAINS } from '../data/knowledgeGraph'; import NeuralFlowGrid from './brain/NeuralFlowGrid'; import BrainFragments from './brain/BrainFragments'; import { getDrillDown, subscribeDrillDown } from '../utils/drilldown'; +import { useThreeTokens } from '../utils/threeTokens'; function FocusController({ selected }) { const { camera } = useThree(); @@ -138,10 +139,11 @@ function BrainNode({ id, activity, selected, onSelect, quality, knowledgeMode, a } function BrainEdges({ weights, quality }) { + const tokens = useThreeTokens(); return LINKS.map(([a, b]) => { const key = `${a}\u2192${b}`; const w = weights[key] ?? 0.2; - const color = key === 'BG\u2192THL' ? '#dd6974' : '#4fa8b3'; + const color = key === 'BG\u2192THL' ? tokens.danger : tokens.accent; return ( new THREE.Vector3(), []); const tempEnd = useMemo(() => new THREE.Vector3(), []); const density = quality === 'ultra' ? 4 : quality === 'high' ? 3 : 2; @@ -203,7 +206,7 @@ function SignalParticles({ regions, quality }) { {particles.map((p) => ( - + ))} @@ -212,6 +215,7 @@ function SignalParticles({ regions, quality }) { export default function BrainScene({ regions, weights, selected, onSelect, quality, onQualityChange, knowledgeMode, affectOverride }) { const dpr = quality === 'ultra' ? [1, 2] : quality === 'high' ? [1, 1.5] : 1; + const tokens = useThreeTokens(); return ( onQualityChange((q) => (q === 'low' ? 'high' : q))} /> - - + + - + {quality !== 'low' && ( @@ -289,7 +293,7 @@ export default function BrainScene({ regions, weights, selected, onSelect, quali - + diff --git a/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx b/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx index d91766e..b635b29 100644 --- a/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx +++ b/brainsnn-r3f-app/src/components/BypassSubmitPanel.jsx @@ -130,14 +130,14 @@ export default function BypassSubmitPanel() {
{result?.error && ( -

{result.error}

+

{result.error}

)} {result?.ok && ( -

+

{result.message}

)} - {err &&

Feed error: {err}

} + {err &&

Feed error: {err}

} {feed && (
diff --git a/brainsnn-r3f-app/src/components/CalendarHeatmapPanel.jsx b/brainsnn-r3f-app/src/components/CalendarHeatmapPanel.jsx index 458f8d1..c5f5bcf 100644 --- a/brainsnn-r3f-app/src/components/CalendarHeatmapPanel.jsx +++ b/brainsnn-r3f-app/src/components/CalendarHeatmapPanel.jsx @@ -73,9 +73,9 @@ export default function CalendarHeatmapPanel() {
Less
-
+
-
+
More / higher-pressure
diff --git a/brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx b/brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx new file mode 100644 index 0000000..eeabc11 --- /dev/null +++ b/brainsnn-r3f-app/src/components/CapabilitiesPanel.jsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; +import { capabilitySnapshot, hasWebGPU } from '../utils/capabilities'; + +/** + * CapabilitiesPanel — surfaces what's available under the hood. + * + * Companion to Privacy Budget. Lets the user (and the user's IT + * sysadmin, in enterprise) see which Beast-mode features are active + * and which the current browser doesn't support. + */ +export default function CapabilitiesPanel() { + const [snap, setSnap] = useState(() => capabilitySnapshot()); + const [webgpu, setWebgpu] = useState(null); + + useEffect(() => { + hasWebGPU().then(setWebgpu); + }, []); + + const refresh = () => setSnap(capabilitySnapshot()); + + const rows = [ + { label: 'CPU cores', value: snap.cores, kind: 'info' }, + { label: 'Web Worker', value: snap.worker }, + { label: 'IndexedDB', value: snap.indexedDB }, + { label: 'BroadcastChannel', value: snap.broadcastChannel, hint: 'multi-tab brain sync' }, + { label: 'Web Locks', value: snap.webLocks, hint: 'atomic snapshot writes' }, + { label: 'Background Sync', value: snap.backgroundSync, hint: 'offline write queue' }, + { label: 'OffscreenCanvas', value: snap.offscreenCanvas, hint: 'unblocks renderer (future)' }, + { label: 'File System Access', value: snap.fileSystemAccess, hint: 'skip download dialogs' }, + { label: 'WebGPU', value: webgpu, hint: 'experimental renderer (future)' } + ]; + + return ( +
+
Layer 103 · Capabilities
+

What's available under the hood

+

+ Beast-mode features depend on browser support. This is what your current browser + actually offers. Refresh after switching browsers or updating to re-probe. +

+ +
+ {rows.map((r) => ( +
+ {r.label} + + {r.kind === 'info' ? r.value + : r.value === null ? '…probing' + : r.value ? '✓ available' : '— missing'} + + {r.hint || ''} +
+ ))} +
+ + +
+ ); +} diff --git a/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx b/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx index b4ac79d..9e034da 100644 --- a/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx +++ b/brainsnn-r3f-app/src/components/CodeBrainPanel.jsx @@ -1,7 +1,6 @@ -import React, { useMemo, useState } from 'react'; -import { parseFiles } from '../utils/codeParser'; -import { buildBM25Index, hybridSearch, hybridSearchSemantic } from '../utils/bm25'; -import { detectCommunities } from '../utils/communities'; +import React, { useState } from 'react'; +import { hybridSearchSemantic } from '../utils/bm25'; +import { analyzeCodeAsync, hybridSearchAsync } from '../utils/searchWorker'; import { isReady as embeddingsReady, embed, cosineSimilarity } from '../utils/embeddings'; /** @@ -17,31 +16,36 @@ import { isReady as embeddingsReady, embed, cosineSimilarity } from '../utils/em */ export default function CodeBrainPanel({ onApplyToNetwork }) { const [input, setInput] = useState(EXAMPLE_INPUT); - const [graph, setGraph] = useState(null); + // `parsed` now holds the FULL analyzed shape (graph + communities + index) + // since the worker computes them in one round trip — no need for a + // separate useMemo over the graph. + const [parsed, setParsed] = useState(null); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); + const [analyzing, setAnalyzing] = useState(false); - const parsed = useMemo(() => { - if (!graph) return null; - const communities = detectCommunities({ nodes: graph.nodes, edges: graph.edges }); - const index = buildBM25Index(graph.docs); - return { ...graph, communities, index }; - }, [graph]); - - function handleAnalyze() { + async function handleAnalyze() { const files = parseInput(input); if (!files.length) { - setGraph(null); + setParsed(null); return; } - const result = parseFiles(files); - setGraph(result); - setResults([]); + setAnalyzing(true); + try { + const result = await analyzeCodeAsync(files); + setParsed(result); + setResults([]); + } finally { + setAnalyzing(false); + } } async function handleSearch() { if (!parsed?.index || !query.trim()) return; if (embeddingsReady()) { + // Semantic path still runs inline — it needs embed() which + // marshals through its own worker; double-hopping a function + // across two postMessage boundaries is more cost than benefit. const { results: ranked } = await hybridSearchSemantic(query, parsed.index, { topK: 8, embedFn: embed, @@ -49,7 +53,7 @@ export default function CodeBrainPanel({ onApplyToNetwork }) { }); setResults(ranked); } else { - setResults(hybridSearch(query, parsed.index, { topK: 8 })); + setResults(await hybridSearchAsync(query, parsed.index, { topK: 8 })); } } diff --git a/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx b/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx index 083c63f..9583198 100644 --- a/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx +++ b/brainsnn-r3f-app/src/components/CognitiveFirewallPanel.jsx @@ -13,6 +13,7 @@ import { import { matchSemanticTemplates, mergeTemplateResults } from '../utils/semanticTemplates'; import { issueReceipt, storeReceipt } from '../utils/receipt'; import { isReady as embeddingsReady } from '../utils/embeddings'; +import { bus } from '../shell/bus'; import { detectArchetypes } from '../utils/adTransparency'; import { markPolyglotSeen } from '../utils/badges'; import { recordScan as recordContextScan } from '../utils/contextMemory'; @@ -101,6 +102,23 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan = } }, [initialScan?.autoApply]); + // AppShell composer: when the user types in the persistent top bar + // and submits with mode='scan', drop the text in here and trigger + // the same scan flow as if they'd typed it in the panel. AppShell + // emits `shell:goto` first so this panel is mounted before the + // composer event reaches us. + useEffect(() => bus.on('shell:compose', ({ text: composed, mode }) => { + if (mode !== 'scan' || !composed) return; + setText(composed); + // Defer the actual scan one tick so React commits the new text + // first; users see the textarea populate, then results animate in. + setTimeout(() => { + const out = scoreContent(composed); + setResult(out); + if (onApplyToNetwork) onApplyToNetwork(out); + }, 0); + }), [onApplyToNetwork]); + // Layer 49 — Scan Anywhere: accept ?scan= and ?scan-url= // on initial mount, pre-fill the textarea, optionally auto-scan. useEffect(() => { @@ -247,7 +265,7 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan = ? (result.emotionalActivation + result.cognitiveSuppression + result.manipulationPressure) / 3 : null; - const riskColor = !overall ? '#4fa8b3' : overall > 0.65 ? '#dd6974' : overall > 0.35 ? '#fdab43' : '#6daa45'; + const riskColor = !overall ? 'var(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? 'var(--severity-mid)' : 'var(--ok)'; return (
@@ -288,7 +306,7 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan =
{fetchError && ( -

+

Fetch failed: {fetchError}. Paste the text manually instead.

)} @@ -398,7 +416,7 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan = title={`${tpl.desc} — click for the counter-response${tpl.source === 'semantic' ? ' (semantic match)' : ''}`} style={{ background: openRefutation === tpl.id ? 'rgba(168,111,223,0.28)' : 'rgba(168,111,223,0.12)', - borderColor: tpl.source === 'semantic' ? '#5ad4ff' : '#a86fdf', + borderColor: tpl.source === 'semantic' ? '#5ad4ff' : 'var(--severity-purple)', color: '#e2d3ff', cursor: 'pointer', }} @@ -420,7 +438,7 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan = key={arch.id} className="firewall-chip" title={`${arch.desc} (matched: ${arch.matched.join(', ')})`} - style={{ background: 'rgba(221,105,116,0.10)', borderColor: '#dd6974', color: '#ffd6da' }} + style={{ background: 'color-mix(in srgb, var(--danger) 10%, transparent)', borderColor: 'var(--danger)', color: '#ffd6da' }} > {arch.label} @@ -582,7 +600,7 @@ export default function CognitiveFirewallPanel({ onApplyToNetwork, initialScan = engine: {draft.engine} · pressure{' '} {Math.round(draft.beforePressure * 100)}% →{' '} - {Math.round(draft.afterPressure * 100)}% + {Math.round(draft.afterPressure * 100)}% −{Math.round(draft.reduction * 100)} pts diff --git a/brainsnn-r3f-app/src/components/CommandPalette.jsx b/brainsnn-r3f-app/src/components/CommandPalette.jsx index 84650a8..49d8d45 100644 --- a/brainsnn-r3f-app/src/components/CommandPalette.jsx +++ b/brainsnn-r3f-app/src/components/CommandPalette.jsx @@ -1,13 +1,17 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { LAYER_CATALOG, LAYER_GROUPS } from '../utils/layerCatalog'; +import { bus } from '../shell/bus'; +import { flashLayer } from '../utils/flashLayer'; /** * Layer 92 — Command Palette * * ⌘K / Ctrl-K opens a centered fuzzy-search overlay. Each match is - * a layer entry; selecting scrolls the matching panel into view (by - * searching the DOM for a panel whose eyebrow-label contains the - * layer id) and briefly highlights it. + * a layer entry; selecting routes through src/utils/flashLayer.js + * which dispatches `shell:goto` to mount the destination workspace + * BEFORE scrolling — without that hop, layer jumps to unvisited + * workspaces would silently no-op (the panel tree isn't in the DOM + * until its workspace mounts). */ function fuzzyScore(needle, hay) { @@ -25,27 +29,6 @@ function fuzzyScore(needle, hay) { return 0.5; } -function findAndFlashPanel(layerId) { - try { - // Every layer panel has an eyebrow "Layer N ·" at its top; find it - const re = new RegExp(`\\blayer\\s*${layerId}\\b`, 'i'); - const labels = document.querySelectorAll('.eyebrow'); - for (const el of labels) { - if (re.test(el.textContent || '')) { - const panel = el.closest('.panel'); - if (!panel) continue; - panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); - const prev = panel.style.boxShadow; - panel.style.transition = 'box-shadow 400ms ease'; - panel.style.boxShadow = '0 0 0 3px rgba(90,212,255,0.6)'; - setTimeout(() => { panel.style.boxShadow = prev; }, 1400); - return true; - } - } - } catch { /* noop */ } - return false; -} - export default function CommandPalette() { const [open, setOpen] = useState(false); const [q, setQ] = useState(''); @@ -66,6 +49,10 @@ export default function CommandPalette() { return () => window.removeEventListener('keydown', onKey); }, []); + // AppShell's Topbar ⌘K button (and anything else that wants to + // surface the palette) emits `shell:palette-open` on the bus. + useEffect(() => bus.on('shell:palette-open', () => setOpen((v) => !v)), []); + useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 0); }, [open]); useEffect(() => { setIdx(0); }, [q, open]); @@ -85,7 +72,9 @@ export default function CommandPalette() { function pick(row) { setOpen(false); setQ(''); - setTimeout(() => findAndFlashPanel(row.id), 50); + // flashLayer dispatches shell:goto, waits two rAFs for the + // workspace to mount, then scrolls + rings the matched panel. + flashLayer(row.id); } if (!open) return null; diff --git a/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx b/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx index c971318..9f39fbd 100644 --- a/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx +++ b/brainsnn-r3f-app/src/components/CommunityPackPanel.jsx @@ -88,7 +88,7 @@ export default function CommunityPackPanel() {
- {err &&

{err}

} + {err &&

{err}

} {pack && (
@@ -119,7 +119,7 @@ export default function CommunityPackPanel() { fontSize: 11, }} > - {r.category}{' '} + {r.category}{' '} /{r.pattern}/ {r.label && — {r.label}}
@@ -136,7 +136,7 @@ export default function CommunityPackPanel() {
)} - {info &&

{info}

} + {info &&

{info}

} ); } diff --git a/brainsnn-r3f-app/src/components/ComparatorPanel.jsx b/brainsnn-r3f-app/src/components/ComparatorPanel.jsx index dc4c16d..fc62754 100644 --- a/brainsnn-r3f-app/src/components/ComparatorPanel.jsx +++ b/brainsnn-r3f-app/src/components/ComparatorPanel.jsx @@ -37,8 +37,8 @@ export default function ComparatorPanel() { {report && (
- - + +
Pressure delta (B - A) - 0 ? '#5ee69a' : report.delta < 0 ? '#dd6974' : '#94a3b8', fontFamily: 'monospace' }}> + 0 ? 'var(--severity-ok)' : report.delta < 0 ? 'var(--danger)' : '#94a3b8', fontFamily: 'monospace' }}> {report.delta > 0 ? '+' : ''}{Math.round(report.delta * 100)} pts
- + - +
)} diff --git a/brainsnn-r3f-app/src/components/ComplimentPanel.jsx b/brainsnn-r3f-app/src/components/ComplimentPanel.jsx index 2368f24..f93d93c 100644 --- a/brainsnn-r3f-app/src/components/ComplimentPanel.jsx +++ b/brainsnn-r3f-app/src/components/ComplimentPanel.jsx @@ -52,7 +52,7 @@ export default function ComplimentPanel() { Genuine {Math.round(result.genuineness * 100)}% ·{' '} Love-bombing risk{' '} - = 0.5 ? '#dd6974' : '#cbd5e1' }}> + = 0.5 ? 'var(--danger)' : '#cbd5e1' }}> {Math.round(result.loveBombingRisk * 100)}% @@ -70,8 +70,8 @@ export default function ComplimentPanel() { }} > - - + +
)} @@ -79,7 +79,7 @@ export default function ComplimentPanel() { ); } -function Stat({ label, value, tone = '#5ee69a' }) { +function Stat({ label, value, tone = 'var(--severity-ok)' }) { return (
{label}
diff --git a/brainsnn-r3f-app/src/components/ComposerPanel.jsx b/brainsnn-r3f-app/src/components/ComposerPanel.jsx index 853fb6f..2819dbd 100644 --- a/brainsnn-r3f-app/src/components/ComposerPanel.jsx +++ b/brainsnn-r3f-app/src/components/ComposerPanel.jsx @@ -80,7 +80,7 @@ export default function ComposerPanel() {
)} - {result?.error &&

{result.error}

} + {result?.error &&

{result.error}

} ); } diff --git a/brainsnn-r3f-app/src/components/ContextMemoryPanel.jsx b/brainsnn-r3f-app/src/components/ContextMemoryPanel.jsx index 6374d98..bde7315 100644 --- a/brainsnn-r3f-app/src/components/ContextMemoryPanel.jsx +++ b/brainsnn-r3f-app/src/components/ContextMemoryPanel.jsx @@ -44,7 +44,7 @@ export default function ContextMemoryPanel() {
- +
{entities.length === 0 ? ( @@ -57,7 +57,7 @@ export default function ContextMemoryPanel() {
Entities ({entities.length})
{entities.map((e) => { - const tone = e.meanPressure >= 0.55 ? '#dd6974' : e.meanPressure >= 0.25 ? '#fdab43' : '#6daa45'; + const tone = e.meanPressure >= 0.55 ? 'var(--danger)' : e.meanPressure >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
-
@@ -150,7 +150,7 @@ function Timeline({ points }) { cx={xs[i]} cy={ys[i]} r={p.pressure > 0.55 ? 3 : 1.8} - fill={p.pressure > 0.55 ? '#dd6974' : '#5ad4ff'} + fill={p.pressure > 0.55 ? 'var(--danger)' : '#5ad4ff'} /> ))} diff --git a/brainsnn-r3f-app/src/components/CustomRulesPanel.jsx b/brainsnn-r3f-app/src/components/CustomRulesPanel.jsx index 7f4a0c6..79a1053 100644 --- a/brainsnn-r3f-app/src/components/CustomRulesPanel.jsx +++ b/brainsnn-r3f-app/src/components/CustomRulesPanel.jsx @@ -92,8 +92,8 @@ export default function CustomRulesPanel() {
- {err &&

{err}

} - {info &&

{info}

} + {err &&

{err}

} + {info &&

{info}

} {rules.length > 0 && (
@@ -114,7 +114,7 @@ export default function CustomRulesPanel() { }} > - {r.category} + {r.category} {' '}· /{r.pattern}/{r.flags} {r.label && — {r.label}} diff --git a/brainsnn-r3f-app/src/components/DailyChallengePanel.jsx b/brainsnn-r3f-app/src/components/DailyChallengePanel.jsx index eed7d51..f8de194 100644 --- a/brainsnn-r3f-app/src/components/DailyChallengePanel.jsx +++ b/brainsnn-r3f-app/src/components/DailyChallengePanel.jsx @@ -229,7 +229,7 @@ export default function DailyChallengePanel({ initialHash = null }) { key={it.id} style={{ padding: '10px 12px', - borderLeft: `3px solid ${ok ? '#5ee69a' : '#dd6974'}`, + borderLeft: `3px solid ${ok ? 'var(--severity-ok)' : 'var(--danger)'}`, background: 'rgba(255,255,255,0.03)', borderRadius: 6, marginTop: 8, @@ -237,7 +237,7 @@ export default function DailyChallengePanel({ initialHash = null }) { >
{it.label} - + truth {it.truth} · you {g ?? '–'} {ok ? '✓' : '✗'}
diff --git a/brainsnn-r3f-app/src/components/DebatePanel.jsx b/brainsnn-r3f-app/src/components/DebatePanel.jsx index 2e13b81..e888db1 100644 --- a/brainsnn-r3f-app/src/components/DebatePanel.jsx +++ b/brainsnn-r3f-app/src/components/DebatePanel.jsx @@ -58,12 +58,12 @@ export default function DebatePanel() { style={{ padding: '10px 12px', borderRadius: 6, - borderLeft: '3px solid #dd6974', - background: 'rgba(221,105,116,0.06)', + borderLeft: '3px solid var(--danger)', + background: 'color-mix(in srgb, var(--danger) 6%, transparent)', }} > {report.peak.speaker}{' '} - {Math.round(report.peak.pressure * 100)}% + {Math.round(report.peak.pressure * 100)}%

"{report.peak.text.slice(0, 200)}"

@@ -77,13 +77,13 @@ export default function DebatePanel() { } function SpeakerCard({ name, mean, count, winner }) { - const tone = mean >= 0.55 ? '#dd6974' : mean >= 0.25 ? '#fdab43' : '#6daa45'; + const tone = mean >= 0.55 ? 'var(--danger)' : mean >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
@@ -113,10 +113,10 @@ function CumulativeGraph({ report }) { return ( - - - {report.aName} - {report.bName} + + + {report.aName} + {report.bName} ); } diff --git a/brainsnn-r3f-app/src/components/DiagnosticPanel.jsx b/brainsnn-r3f-app/src/components/DiagnosticPanel.jsx index 3ad743f..3f4976c 100644 --- a/brainsnn-r3f-app/src/components/DiagnosticPanel.jsx +++ b/brainsnn-r3f-app/src/components/DiagnosticPanel.jsx @@ -82,13 +82,13 @@ export default function DiagnosticPanel() { style={{ padding: '4px 8px', borderRadius: 4, - background: 'rgba(221,105,116,0.06)', + background: 'color-mix(in srgb, var(--danger) 6%, transparent)', marginTop: 4, fontFamily: 'monospace', fontSize: 11, }} > - {p.category}{' '} + {p.category}{' '} /{p.source.slice(0, 80)}/
))} @@ -114,7 +114,7 @@ export default function DiagnosticPanel() { fontSize: 11, }} > - {p.category}{' '} + {p.category}{' '} ({p.benignHits} benign hits){' '} /{p.source.slice(0, 80)}/
diff --git a/brainsnn-r3f-app/src/components/DiffPanel.jsx b/brainsnn-r3f-app/src/components/DiffPanel.jsx index f62eeca..93b4679 100644 --- a/brainsnn-r3f-app/src/components/DiffPanel.jsx +++ b/brainsnn-r3f-app/src/components/DiffPanel.jsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { bus } from '../shell/bus'; import { runDiff, diffVerdict, buildDiffPayload, diffUrl, DIFF_EXAMPLE } from '../utils/diffMode'; /** @@ -12,6 +13,12 @@ export default function DiffPanel() { const [textB, setTextB] = useState(''); const [copied, setCopied] = useState(false); + // Composer 'diff' mode drops the text into A; B stays empty for + // the user to paste the variant. + useEffect(() => bus.on('shell:compose', ({ text, mode }) => { + if (mode === 'diff' && text) setTextA(text); + }), []); + const diff = useMemo(() => { if (!textA.trim() || !textB.trim()) return null; return runDiff({ labelA, textA, labelB, textB }); @@ -126,7 +133,7 @@ export default function DiffPanel() { style={{ padding: '10px 12px', borderRadius: 6, - borderLeft: `3px solid ${side.pressure > 0.5 ? '#dd6974' : side.pressure > 0.25 ? '#fdab43' : '#6daa45'}`, + borderLeft: `3px solid ${side.pressure > 0.5 ? 'var(--danger)' : side.pressure > 0.25 ? 'var(--severity-mid)' : 'var(--ok)'}`, background: 'rgba(255,255,255,0.03)', }} > diff --git a/brainsnn-r3f-app/src/components/EchoPanel.jsx b/brainsnn-r3f-app/src/components/EchoPanel.jsx index ebe8397..37e43f5 100644 --- a/brainsnn-r3f-app/src/components/EchoPanel.jsx +++ b/brainsnn-r3f-app/src/components/EchoPanel.jsx @@ -78,8 +78,8 @@ export default function EchoPanel() { marginTop: 10, padding: '10px 12px', borderRadius: 8, - borderLeft: '3px solid #dd6974', - background: 'rgba(221,105,116,0.06)', + borderLeft: '3px solid var(--danger)', + background: 'color-mix(in srgb, var(--danger) 6%, transparent)', }} >
diff --git a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx index ccd1ace..afd9133 100644 --- a/brainsnn-r3f-app/src/components/FeedbackPanel.jsx +++ b/brainsnn-r3f-app/src/components/FeedbackPanel.jsx @@ -1,18 +1,23 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { calibrationReport, listFeedback, clearFeedback } from '../utils/feedback'; +import { subscribe as subscribeMultiTab } from '../utils/multiTab'; export default function FeedbackPanel() { const [report, setReport] = useState(() => calibrationReport()); const [rows, setRows] = useState(() => listFeedback()); - useEffect(() => { - const id = setInterval(() => { - setReport(calibrationReport()); - setRows(listFeedback()); - }, 3000); - return () => clearInterval(id); + const refresh = useCallback(() => { + setReport(calibrationReport()); + setRows(listFeedback()); }, []); + // feedback.js publishes 'feedback:changed' on each recordFeedback / + // clearFeedback. Locked writes are async, so this subscription + // patches the UI right after the write commits. Replaces the + // previous 3s polling interval — live updates make polling + // redundant (saves a setInterval on every panel mount). + useEffect(() => subscribeMultiTab('feedback:changed', refresh), [refresh]); + function wipe() { if (!window.confirm('Clear all calibration feedback?')) return; clearFeedback(); @@ -20,7 +25,7 @@ export default function FeedbackPanel() { setRows(listFeedback()); } - const tone = report.suggestedMul > 1.05 ? '#fdab43' : report.suggestedMul < 0.95 ? '#77dbe4' : '#5ee69a'; + const tone = report.suggestedMul > 1.05 ? 'var(--severity-mid)' : report.suggestedMul < 0.95 ? 'var(--severity-info)' : 'var(--severity-ok)'; return (
@@ -41,17 +46,17 @@ export default function FeedbackPanel() {
- too cold {report.tooCold} - accurate {report.accurate} - too hot {report.tooHot} - + too cold {report.tooCold} + accurate {report.accurate} + too hot {report.tooHot} +
{rows.length > 0 && (
Recent ratings
{rows.slice(-10).reverse().map((r, i) => { - const color = r.verdict === 'too_hot' ? '#dd6974' : r.verdict === 'too_cold' ? '#77dbe4' : '#5ee69a'; + const color = r.verdict === 'too_hot' ? 'var(--danger)' : r.verdict === 'too_cold' ? 'var(--severity-info)' : 'var(--severity-ok)'; return (
{label}
-
+
-
+
))} diff --git a/brainsnn-r3f-app/src/components/GeminiAnalysisPanel.jsx b/brainsnn-r3f-app/src/components/GeminiAnalysisPanel.jsx index 9ba1406..0e8deb5 100644 --- a/brainsnn-r3f-app/src/components/GeminiAnalysisPanel.jsx +++ b/brainsnn-r3f-app/src/components/GeminiAnalysisPanel.jsx @@ -88,7 +88,7 @@ export default function GeminiAnalysisPanel({ onApplyToNetwork }) { const overall = result ? (result.emotionalActivation + result.cognitiveSuppression + result.manipulationPressure) / 3 : null; - const riskColor = !overall ? 'var(--primary)' : overall > 0.65 ? '#dd6974' : overall > 0.35 ? '#fdab43' : '#6daa45'; + const riskColor = !overall ? 'var(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? 'var(--severity-mid)' : 'var(--ok)'; const statusColor = status === 'online' ? 'var(--ok)' : status === 'offline' ? 'var(--danger)' : 'var(--muted)'; return ( diff --git a/brainsnn-r3f-app/src/components/GemmaAnalysisPanel.jsx b/brainsnn-r3f-app/src/components/GemmaAnalysisPanel.jsx index 6aea548..96a8849 100644 --- a/brainsnn-r3f-app/src/components/GemmaAnalysisPanel.jsx +++ b/brainsnn-r3f-app/src/components/GemmaAnalysisPanel.jsx @@ -74,7 +74,7 @@ export default function GemmaAnalysisPanel({ onApplyToNetwork }) { ? (result.emotionalActivation + result.cognitiveSuppression + result.manipulationPressure) / 3 : null; - const riskColor = !overall ? 'var(--primary)' : overall > 0.65 ? '#dd6974' : overall > 0.35 ? '#fdab43' : '#6daa45'; + const riskColor = !overall ? 'var(--accent)' : overall > 0.65 ? 'var(--danger)' : overall > 0.35 ? 'var(--severity-mid)' : 'var(--ok)'; const statusColor = status === 'online' ? 'var(--ok)' : status === 'offline' ? 'var(--danger)' : 'var(--muted)'; const modelName = getGemmaModel() || 'Gemma'; diff --git a/brainsnn-r3f-app/src/components/HotkeyMap.jsx b/brainsnn-r3f-app/src/components/HotkeyMap.jsx deleted file mode 100644 index 0bd57aa..0000000 --- a/brainsnn-r3f-app/src/components/HotkeyMap.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { listHotkeys, layerForHotkey, flashLayerInDom } from '../utils/hotkeys'; - -/** - * Layer 97 — Hotkey Map - * - * Shift-? opens a fullscreen hotkey cheat-sheet. Typing two letters - * (while not in a form field) jumps to the mapped panel. - */ -export default function HotkeyMap() { - const [open, setOpen] = useState(false); - const [buf, setBuf] = useState(''); - - useEffect(() => { - function onKey(e) { - if (e.key === '?' && e.shiftKey) { e.preventDefault(); setOpen((v) => !v); return; } - if (e.key === 'Escape') { setOpen(false); setBuf(''); return; } - // Don't swallow keys inside form inputs - const t = e.target; - const inInput = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); - if (inInput) return; - if (e.metaKey || e.ctrlKey || e.altKey) return; - if (!/^[a-zA-Z]$/.test(e.key)) return; - setBuf((prev) => { - const next = (prev + e.key.toLowerCase()).slice(-2); - if (next.length === 2) { - const layer = layerForHotkey(next); - if (layer != null) { flashLayerInDom(layer); setTimeout(() => setBuf(''), 400); return ''; } - } - return next; - }); - } - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, []); - - const rows = listHotkeys(); - - if (!open) return buf ? ( -
- ▶ {buf}_ -
- ) : null; - - return ( -
setOpen(false)} - style={{ - position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', - zIndex: 9995, display: 'flex', alignItems: 'center', justifyContent: 'center', - }} - > -
e.stopPropagation()} - style={{ - width: 'min(760px, 94vw)', maxHeight: '80vh', overflowY: 'auto', - background: '#0b1224', border: '1px solid rgba(255,255,255,0.08)', - borderRadius: 12, padding: 20, boxShadow: '0 40px 80px rgba(0,0,0,0.6)', - }} - > -
-

Hotkey Map · Layer 97

- Shift-? to toggle · esc to close -
-

- Type two letters anywhere (not in a form field) to jump to that panel. -

-
- {rows.map(({ key, layer }) => ( -
- {key} - L{layer.id} - {layer.name} -
- ))} -
-
-
- ); -} diff --git a/brainsnn-r3f-app/src/components/HypothesisPanel.jsx b/brainsnn-r3f-app/src/components/HypothesisPanel.jsx index c5ec413..b3d3cd1 100644 --- a/brainsnn-r3f-app/src/components/HypothesisPanel.jsx +++ b/brainsnn-r3f-app/src/components/HypothesisPanel.jsx @@ -82,13 +82,13 @@ export default function HypothesisPanel() { style={{ padding: '8px 12px', borderRadius: 6, - borderLeft: `3px solid ${r.matches ? '#5ee69a' : '#94a3b8'}`, + borderLeft: `3px solid ${r.matches ? 'var(--severity-ok)' : '#94a3b8'}`, background: r.matches ? 'rgba(94,230,154,0.05)' : 'rgba(255,255,255,0.02)', marginTop: 6, }} >
- + {r.matches ? '✓ supports' : '– against'} @@ -109,7 +109,7 @@ export default function HypothesisPanel() {
)} - {report?.error &&

{report.error}

} + {report?.error &&

{report.error}

} ); } diff --git a/brainsnn-r3f-app/src/components/ImmunityPanel.jsx b/brainsnn-r3f-app/src/components/ImmunityPanel.jsx index 1732ef9..962f4f7 100644 --- a/brainsnn-r3f-app/src/components/ImmunityPanel.jsx +++ b/brainsnn-r3f-app/src/components/ImmunityPanel.jsx @@ -251,7 +251,7 @@ export default function ImmunityPanel({ incomingCard = null }) {
{submitResult?.error && ( -

+

{submitResult.error}

)} @@ -282,7 +282,7 @@ export default function ImmunityPanel({ incomingCard = null }) {
{boardError && ( -

+

Leaderboard error: {boardError}

)} diff --git a/brainsnn-r3f-app/src/components/InboxPanel.jsx b/brainsnn-r3f-app/src/components/InboxPanel.jsx index 8ffbea0..62461b4 100644 --- a/brainsnn-r3f-app/src/components/InboxPanel.jsx +++ b/brainsnn-r3f-app/src/components/InboxPanel.jsx @@ -103,7 +103,7 @@ export default function InboxPanel() { key={it.idx} style={{ padding: '10px 12px', - borderLeft: `3px solid ${it.pressure > 0.55 ? '#dd6974' : it.pressure > 0.3 ? '#fdab43' : '#6daa45'}`, + borderLeft: `3px solid ${it.pressure > 0.55 ? 'var(--danger)' : it.pressure > 0.3 ? 'var(--severity-mid)' : 'var(--ok)'}`, background: 'rgba(255,255,255,0.03)', borderRadius: 6, marginTop: 8, diff --git a/brainsnn-r3f-app/src/components/InspectorPanel.jsx b/brainsnn-r3f-app/src/components/InspectorPanel.jsx index f742e63..e94582d 100644 --- a/brainsnn-r3f-app/src/components/InspectorPanel.jsx +++ b/brainsnn-r3f-app/src/components/InspectorPanel.jsx @@ -21,7 +21,7 @@ function Sparkline({ history }) { return ( - + ); } diff --git a/brainsnn-r3f-app/src/components/JournalismPanel.jsx b/brainsnn-r3f-app/src/components/JournalismPanel.jsx index cf1d528..df175f0 100644 --- a/brainsnn-r3f-app/src/components/JournalismPanel.jsx +++ b/brainsnn-r3f-app/src/components/JournalismPanel.jsx @@ -79,7 +79,7 @@ export default function JournalismPanel() { {parsed.headers.length} columns

)} - {err &&

{err}

} + {err &&

{err}

}
diff --git a/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx b/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx index 03153bc..9a7a23e 100644 --- a/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx +++ b/brainsnn-r3f-app/src/components/PersonalDictionaryPanel.jsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { listEntries, addEntry, removeEntry, clearAll, scanPersonal, } from '../utils/personalDictionary'; +import { subscribe as subscribeMultiTab } from '../utils/multiTab'; export default function PersonalDictionaryPanel() { const [entries, setEntries] = useState(() => listEntries()); @@ -12,7 +13,16 @@ export default function PersonalDictionaryPanel() { const [testText, setTestText] = useState(''); const [err, setErr] = useState(''); - useEffect(() => { setEntries(listEntries()); }, []); + const refresh = useCallback(() => setEntries(listEntries()), []); + + useEffect(() => { refresh(); }, [refresh]); + + // personalDictionary.js publishes 'personalDict:changed' on every + // mutation. Locked writes run in a microtask, so the synchronous + // setEntries(listEntries()) right after addEntry/removeEntry sees + // stale data; this subscription patches the list once the write + // commits. Also keeps a second tab in sync. + useEffect(() => subscribeMultiTab('personalDict:changed', refresh), [refresh]); function add() { setErr(''); @@ -72,7 +82,7 @@ export default function PersonalDictionaryPanel() {
- {err &&

{err}

} + {err &&

{err}

} {entries.length === 0 ? (

Empty — add your first phrase above.

@@ -99,11 +109,11 @@ export default function PersonalDictionaryPanel() {
{e.tag || '—'} w {e.weight.toFixed(2)} · {e.hits}× - +
))}
- +
)} @@ -129,8 +139,8 @@ export default function PersonalDictionaryPanel() { key={m.id} style={{ padding: '4px 10px', - borderLeft: '3px solid #dd6974', - background: 'rgba(221,105,116,0.06)', + borderLeft: '3px solid var(--danger)', + background: 'color-mix(in srgb, var(--danger) 6%, transparent)', borderRadius: 4, marginTop: 4, fontSize: 12, diff --git a/brainsnn-r3f-app/src/components/PluginPanel.jsx b/brainsnn-r3f-app/src/components/PluginPanel.jsx index 13a6872..72a52e3 100644 --- a/brainsnn-r3f-app/src/components/PluginPanel.jsx +++ b/brainsnn-r3f-app/src/components/PluginPanel.jsx @@ -87,7 +87,7 @@ export default function PluginPanel({ onApplyResults }) {
{k}
- = 0 ? 'var(--primary)' : 'var(--danger)' }} /> + = 0 ? 'var(--accent)' : 'var(--danger)' }} />
{typeof v === 'number' ? v.toFixed(3) : v}
diff --git a/brainsnn-r3f-app/src/components/PortabilityPanel.jsx b/brainsnn-r3f-app/src/components/PortabilityPanel.jsx index 24844c1..45ccbef 100644 --- a/brainsnn-r3f-app/src/components/PortabilityPanel.jsx +++ b/brainsnn-r3f-app/src/components/PortabilityPanel.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { exportBundle, bundleStats, importBundle, wipeBundle, downloadBundle, countKeys, } from '../utils/portability'; +import { saveText, isAvailable as hasFsa, hasHandle, clearHandle } from '../utils/fileSystemSave'; /** * Layer 57 — Data Portability panel. @@ -12,6 +13,7 @@ export default function PortabilityPanel() { const [err, setErr] = useState(''); const [overwrite, setOverwrite] = useState(false); const [keyCount, setKeyCount] = useState(() => countKeys()); + const [pinned, setPinned] = useState(() => hasFsa() && hasHandle('portability')); function handleDownload() { setErr(''); setInfo(''); @@ -21,6 +23,30 @@ export default function PortabilityPanel() { } catch (e) { setErr(e.message || 'download failed'); } } + async function handleSaveToFile() { + setErr(''); setInfo(''); + try { + const json = exportBundle(); + const result = await saveText('portability', json, `brainsnn-bundle-${new Date().toISOString().slice(0, 10)}.json`); + if (result.saved) { + setPinned(hasFsa() && hasHandle('portability')); + if (result.fallback) { + setInfo(`Downloaded (${keyCount} keys) — your browser doesn't support the in-place save API.`); + } else { + setInfo(`Saved to ${result.name} (${keyCount} keys). Re-clicking will overwrite.`); + } + } else { + setInfo('Save cancelled.'); + } + } catch (e) { setErr(e.message || 'save failed'); } + } + + function handleUnpinFile() { + clearHandle('portability'); + setPinned(false); + setInfo('File pin cleared — next save will re-prompt.'); + } + async function copyExport() { setErr(''); setInfo(''); try { @@ -67,10 +93,26 @@ export default function PortabilityPanel() {

- + {hasFsa() ? ( + + ) : ( + + )} - + {pinned && ( + + )} +
+ {hasFsa() && ( +

+ File System Access available — pick a file once, subsequent saves overwrite without prompting. +

+ )}
Import bundle
@@ -95,8 +137,8 @@ export default function PortabilityPanel() {
- {err &&

{err}

} - {info &&

{info}

} + {err &&

{err}

} + {info &&

{info}

} ); } diff --git a/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx b/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx index 6ea9e51..e6c474b 100644 --- a/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx +++ b/brainsnn-r3f-app/src/components/PrivacyBudgetPanel.jsx @@ -16,7 +16,7 @@ export default function PrivacyBudgetPanel() { const quota = approximateQuota(); const pct = Math.min(100, (budget.totalBytes / quota) * 100); - const tone = pct > 70 ? '#dd6974' : pct > 40 ? '#fdab43' : '#5ee69a'; + const tone = pct > 70 ? 'var(--danger)' : pct > 40 ? 'var(--severity-mid)' : 'var(--severity-ok)'; return (
@@ -81,7 +81,7 @@ export default function PrivacyBudgetPanel() {
L{e.layer} {humanBytes(e.bytes)} -
diff --git a/brainsnn-r3f-app/src/components/PwaInstallPanel.jsx b/brainsnn-r3f-app/src/components/PwaInstallPanel.jsx index 1139914..cdf53ae 100644 --- a/brainsnn-r3f-app/src/components/PwaInstallPanel.jsx +++ b/brainsnn-r3f-app/src/components/PwaInstallPanel.jsx @@ -60,12 +60,12 @@ function StateTile({ label, ok }) { style={{ padding: '10px 12px', borderRadius: 8, - borderLeft: `3px solid ${ok ? '#5ee69a' : 'rgba(255,255,255,0.1)'}`, + borderLeft: `3px solid ${ok ? 'var(--severity-ok)' : 'rgba(255,255,255,0.1)'}`, background: ok ? 'rgba(94,230,154,0.06)' : 'rgba(255,255,255,0.03)', }} >
{label}
- + {ok ? 'available' : 'not yet'} diff --git a/brainsnn-r3f-app/src/components/RedTeamPanel.jsx b/brainsnn-r3f-app/src/components/RedTeamPanel.jsx index 2d130d2..20a4e3c 100644 --- a/brainsnn-r3f-app/src/components/RedTeamPanel.jsx +++ b/brainsnn-r3f-app/src/components/RedTeamPanel.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { runRedTeam, verdict, corpusSize, ATTACK_CATEGORIES } from '../utils/redTeam'; +import { runRedTeamAsync, verdict, corpusSize, ATTACK_CATEGORIES } from '../utils/redTeam'; import { recordEvent as recordImmunity, IMMUNITY_EVENTS } from '../utils/immunityScore'; /** @@ -22,11 +22,11 @@ export default function RedTeamPanel() { setRunning(true); setReport(null); setProgress(0); - // Yield to the browser so the button re-renders before the sync work - await new Promise((r) => setTimeout(r, 20)); - const result = runRedTeam({ - onProgress: (done) => setProgress(done) - }); + // Worker variant — main thread stays free, brain keeps rendering. + // onProgress isn't supported across postMessage; the bar jumps + // from 0 to total when the report lands (still <300 ms on default + // hardware so the snap is barely perceptible). + const result = await runRedTeamAsync(); setReport(result); setRunning(false); setProgress(total); diff --git a/brainsnn-r3f-app/src/components/ReplayPanel.jsx b/brainsnn-r3f-app/src/components/ReplayPanel.jsx index bf3d890..502db8f 100644 --- a/brainsnn-r3f-app/src/components/ReplayPanel.jsx +++ b/brainsnn-r3f-app/src/components/ReplayPanel.jsx @@ -78,7 +78,7 @@ export default function ReplayPanel() { ) : ( )} - - {err &&

{err}

} + {err &&

{err}

} {imported && ( diff --git a/brainsnn-r3f-app/src/components/RulePacksPanel.jsx b/brainsnn-r3f-app/src/components/RulePacksPanel.jsx index 7da7315..2b887cc 100644 --- a/brainsnn-r3f-app/src/components/RulePacksPanel.jsx +++ b/brainsnn-r3f-app/src/components/RulePacksPanel.jsx @@ -35,7 +35,7 @@ export default function RulePacksPanel() { without deleting your own handwritten rules.

- {err &&

{err}

} + {err &&

{err}

}
{RULE_PACKS.map((p) => { @@ -46,7 +46,7 @@ export default function RulePacksPanel() { style={{ padding: '12px 14px', borderRadius: 8, - borderLeft: `3px solid ${on ? '#5ee69a' : 'rgba(255,255,255,0.1)'}`, + borderLeft: `3px solid ${on ? 'var(--severity-ok)' : 'rgba(255,255,255,0.1)'}`, background: on ? 'rgba(94,230,154,0.06)' : 'rgba(255,255,255,0.02)', }} > diff --git a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx index 50da69e..46f0b30 100644 --- a/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx +++ b/brainsnn-r3f-app/src/components/ScanArchivePanel.jsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { listArchive, searchArchive, removeFromArchive, clearArchive, exportArchiveJson, exportArchiveCsv, } from '../utils/scanArchive'; +import { subscribe as subscribeMultiTab } from '../utils/multiTab'; /** * Layer 84 — Scan Archive panel. @@ -11,9 +12,18 @@ export default function ScanArchivePanel() { const [query, setQuery] = useState(''); const [items, setItems] = useState(() => listArchive()); - useEffect(() => { setItems(query ? searchArchive(query) : listArchive()); }, [query]); + const refresh = useCallback( + () => setItems(query ? searchArchive(query) : listArchive()), + [query] + ); + + useEffect(() => { refresh(); }, [refresh]); - function refresh() { setItems(query ? searchArchive(query) : listArchive()); } + // archiveScan/removeFromArchive run their writes through withLock, + // so synchronous reads right after a mutation miss the new state. + // This subscription patches the list once the write commits + keeps + // a second tab in sync. + useEffect(() => subscribeMultiTab('archive:changed', refresh), [refresh]); function remove(id) { removeFromArchive(id); @@ -69,7 +79,7 @@ export default function ScanArchivePanel() { /> - +
@@ -79,7 +89,7 @@ export default function ScanArchivePanel() { after any scan to save it here.

) : items.map((e) => { - const tone = e.pressure >= 0.55 ? '#dd6974' : e.pressure >= 0.25 ? '#fdab43' : '#6daa45'; + const tone = e.pressure >= 0.55 ? 'var(--danger)' : e.pressure >= 0.25 ? 'var(--severity-mid)' : 'var(--ok)'; return (
0 && <> · tags: {e.tags.join(', ')}}
)} -
diff --git a/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx b/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx index a724204..c3ba9aa 100644 --- a/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx +++ b/brainsnn-r3f-app/src/components/SessionRoomsPanel.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { getImmunityState } from '../utils/immunityScore'; import { getStreak } from '../utils/dailyChallenge'; +import { fetchOrQueue, onOnlineChange, isOffline } from '../utils/offlineQueue'; const HANDLE_KEY = 'brainsnn_handle_v1'; const ROOM_KEY = 'brainsnn_last_room_v1'; @@ -37,9 +38,11 @@ export default function SessionRoomsPanel() { const [source, setSource] = useState('daily'); const [loading, setLoading] = useState(false); const [err, setErr] = useState(''); + const [offline, setOffline] = useState(isOffline()); useEffect(() => { writeHandle(handle); }, [handle]); useEffect(() => { if (room) writeLastRoom(room); }, [room]); + useEffect(() => onOnlineChange(({ online }) => setOffline(!online)), []); useEffect(() => { const sharedRoom = readRoomFromUrl(); if (sharedRoom) { @@ -92,13 +95,30 @@ export default function SessionRoomsPanel() { meta = { rawStreak: streak.streak }; } try { - const r = await fetch('/api/rooms', { + const result = await fetchOrQueue('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, handle: handle || 'anon', source, score, streak: streak.streak, meta }), }); - const data = await r.json(); - if (!r.ok) { setErr(data.error || `HTTP ${r.status}`); return; } + if (result.queued) { + setErr(''); + setEntries((prev) => [ + ...prev, + { handle: handle || 'anon', source, score, streak: streak.streak, ts: Date.now(), queued: true } + ]); + return; + } + if (!result.ok) { + const r = result.response; + if (r) { + const data = await r.json().catch(() => ({})); + setErr(data.error || `HTTP ${r.status}`); + } else { + setErr(result.reason || 'submit failed'); + } + return; + } + const data = await result.response.json(); setEntries(data.entries || []); } catch (e) { setErr(e.message || 'submit failed'); } } @@ -113,6 +133,11 @@ export default function SessionRoomsPanel() { higher immunity", or "whose streak is longer". Upstash-backed when the env is set, in-memory fallback otherwise.

+ {offline && ( +

+ ● offline — submissions will queue and replay when you're back online +

+ )}
- {err &&

{err}

} + {err &&

{err}

} {entries.length > 0 && (
@@ -168,7 +193,7 @@ export default function SessionRoomsPanel() { .slice() .sort((a, b) => b.score - a.score) .map((e, i) => { - const tone = i === 0 ? '#5ee69a' : i === 1 ? '#77dbe4' : '#fdab43'; + const tone = i === 0 ? 'var(--severity-ok)' : i === 1 ? 'var(--severity-info)' : 'var(--severity-mid)'; return (
{results.map((r) => { const pct = Math.round(r.score * 100); - const tone = pct >= 60 ? '#5ee69a' : pct >= 30 ? '#77dbe4' : '#fdab43'; + const tone = pct >= 60 ? 'var(--severity-ok)' : pct >= 30 ? 'var(--severity-info)' : 'var(--severity-mid)'; return (
0; @@ -28,6 +29,10 @@ export default function SnapshotPanel({ state, onRestoreSnapshot }) { useEffect(() => { refresh(); }, [refresh]); + // Cross-tab sync: snapshots.js publishes 'snapshot:changed' on every + // mutation so a second tab can refresh its list without polling. + useEffect(() => subscribeMultiTab('snapshot:changed', () => refresh()), [refresh]); + const handleSave = () => { saveSnapshot(state, name); setName(''); diff --git a/brainsnn-r3f-app/src/components/SyncPanel.jsx b/brainsnn-r3f-app/src/components/SyncPanel.jsx index 58bba84..42f82c8 100644 --- a/brainsnn-r3f-app/src/components/SyncPanel.jsx +++ b/brainsnn-r3f-app/src/components/SyncPanel.jsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { exportBundle, importBundle } from '../utils/portability'; +import { fetchOrQueue, onOnlineChange, isOffline } from '../utils/offlineQueue'; function generateCode() { // 6-char uppercase code, easy to read out loud @@ -15,18 +16,35 @@ export default function SyncPanel() { const [status, setStatus] = useState(''); const [err, setErr] = useState(''); const [overwrite, setOverwrite] = useState(false); + const [offline, setOffline] = useState(isOffline()); + + useEffect(() => onOnlineChange(({ online }) => setOffline(!online)), []); async function send() { setErr(''); setStatus('uploading…'); try { const bundle = exportBundle(); - const r = await fetch('/api/sync', { + const result = await fetchOrQueue('/api/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, bundle }), }); - const data = await r.json(); - if (!r.ok) { setErr(data.error || `HTTP ${r.status}`); setStatus(''); return; } + if (result.queued) { + setStatus(`Queued · will sync when online (code: ${code})`); + return; + } + if (!result.ok) { + const r = result.response; + if (r) { + const data = await r.json().catch(() => ({})); + setErr(data.error || `HTTP ${r.status}`); + } else { + setErr(result.reason || 'upload failed'); + } + setStatus(''); + return; + } + const data = await result.response.json(); setStatus(`Uploaded · ${data.bytes.toLocaleString()} bytes · expires in ${Math.round(data.ttlSec / 60)} min`); } catch (e) { setErr(e.message || 'upload failed'); setStatus(''); } } @@ -53,9 +71,14 @@ export default function SyncPanel() { auto-expires. Payload is your Layer 57 export — we don't read it.

-
+
+ {offline && ( + + ● offline — uploads will queue + + )}
@@ -82,7 +105,7 @@ export default function SyncPanel() { )} {status &&

{status}

} - {err &&

{err}

} + {err &&

{err}

} ); } diff --git a/brainsnn-r3f-app/src/components/TextAdventurePanel.jsx b/brainsnn-r3f-app/src/components/TextAdventurePanel.jsx index 0717a14..47b7053 100644 --- a/brainsnn-r3f-app/src/components/TextAdventurePanel.jsx +++ b/brainsnn-r3f-app/src/components/TextAdventurePanel.jsx @@ -69,8 +69,8 @@ export default function TextAdventurePanel() { style={{ padding: '12px 14px', borderRadius: 8, - borderLeft: '3px solid #dd6974', - background: 'rgba(221,105,116,0.06)', + borderLeft: '3px solid var(--danger)', + background: 'color-mix(in srgb, var(--danger) 6%, transparent)', }} >
from {node.from}
diff --git a/brainsnn-r3f-app/src/components/TimeSeriesPanel.jsx b/brainsnn-r3f-app/src/components/TimeSeriesPanel.jsx index ba92f1c..08411e9 100644 --- a/brainsnn-r3f-app/src/components/TimeSeriesPanel.jsx +++ b/brainsnn-r3f-app/src/components/TimeSeriesPanel.jsx @@ -116,15 +116,15 @@ export default function TimeSeriesPanel() { key={e.idx} style={{ padding: '8px 12px', - borderLeft: '3px solid #dd6974', - background: 'rgba(221,105,116,0.06)', + borderLeft: '3px solid var(--danger)', + background: 'color-mix(in srgb, var(--danger) 6%, transparent)', borderRadius: 6, marginTop: 6, }} >
{e.point.date} - + +{Math.round(e.delta * 100)} pts spike
@@ -158,7 +158,7 @@ function Sparkline({ points }) { cx={xs[i]} cy={ys[i]} r={p.pressure > 0.55 ? 3.2 : 2} - fill={p.pressure > 0.55 ? '#dd6974' : '#5ad4ff'} + fill={p.pressure > 0.55 ? 'var(--danger)' : '#5ad4ff'} /> ))} diff --git a/brainsnn-r3f-app/src/components/ToastContainer.jsx b/brainsnn-r3f-app/src/components/ToastContainer.jsx index f148074..d31c5b1 100644 --- a/brainsnn-r3f-app/src/components/ToastContainer.jsx +++ b/brainsnn-r3f-app/src/components/ToastContainer.jsx @@ -7,9 +7,17 @@ function Toast({ toast, onDismiss }) { return () => clearTimeout(timer); }, [toast, onDismiss]); + // Errors + warnings interrupt assistive-tech reading. Info / + // success are polite — they announce when the reader is idle. + const live = toast.type === 'error' || toast.type === 'warning' ? 'assertive' : 'polite'; return ( -
onDismiss(toast.id)}> - +
onDismiss(toast.id)} + > + {toast.type === 'success' ? '✓' : toast.type === 'error' ? '✕' : toast.type === 'warning' ? '⚠' : 'ℹ'} {toast.message} @@ -33,7 +41,7 @@ export default function ToastContainer() { if (toasts.length === 0) return null; return ( -
+
{toasts.map((t) => ( ))} diff --git a/brainsnn-r3f-app/src/components/ToneShifterPanel.jsx b/brainsnn-r3f-app/src/components/ToneShifterPanel.jsx index e3c88ba..8e8d5d7 100644 --- a/brainsnn-r3f-app/src/components/ToneShifterPanel.jsx +++ b/brainsnn-r3f-app/src/components/ToneShifterPanel.jsx @@ -55,13 +55,13 @@ export default function ToneShifterPanel() { style={{ padding: '10px 14px', borderRadius: 8, - borderLeft: '3px solid #dd6974', - background: 'rgba(221,105,116,0.08)', + borderLeft: '3px solid var(--danger)', + background: 'color-mix(in srgb, var(--danger) 8%, transparent)', }} >
{result.style.label} drift - + {Math.round(result.beforePressure * 100)}% → {Math.round(result.afterPressure * 100)}% · +{Math.round(result.increase * 100)} pts
@@ -80,7 +80,7 @@ export default function ToneShifterPanel() {
)} - {result?.error &&

{result.error}

} + {result?.error &&

{result.error}

} ); } diff --git a/brainsnn-r3f-app/src/components/WeeklyRecapPanel.jsx b/brainsnn-r3f-app/src/components/WeeklyRecapPanel.jsx index f089c8e..9b23d5e 100644 --- a/brainsnn-r3f-app/src/components/WeeklyRecapPanel.jsx +++ b/brainsnn-r3f-app/src/components/WeeklyRecapPanel.jsx @@ -48,7 +48,7 @@ export default function WeeklyRecapPanel() { if (!recap) return null; - const deltaColor = recap.immunityDelta > 0 ? '#5ee69a' : recap.immunityDelta < 0 ? '#dd6974' : '#fdab43'; + const deltaColor = recap.immunityDelta > 0 ? 'var(--severity-ok)' : recap.immunityDelta < 0 ? 'var(--danger)' : 'var(--severity-mid)'; return (
diff --git a/brainsnn-r3f-app/src/components/brain/NeuralFlowGrid.jsx b/brainsnn-r3f-app/src/components/brain/NeuralFlowGrid.jsx index 4c429fe..878c602 100644 --- a/brainsnn-r3f-app/src/components/brain/NeuralFlowGrid.jsx +++ b/brainsnn-r3f-app/src/components/brain/NeuralFlowGrid.jsx @@ -4,12 +4,14 @@ import { LINKS, POSITIONS, REGION_INFO } from '../../data/network'; import { findCrossLinks, getEdgeActivity, getCrossLinkActivity } from './flowGridUtils'; import FlowTube from './FlowTube'; import PulseWave from './PulseWave'; +import { useThreeTokens } from '../../utils/threeTokens'; const PULSE_THRESHOLD = 0.6; const MAX_PULSES = 3; export default function NeuralFlowGrid({ regions, weights, quality }) { const [pulses, setPulses] = useState([]); + const tokens = useThreeTokens(); const prevRegionsRef = useRef({}); const pulseIdRef = useRef(0); @@ -74,7 +76,7 @@ export default function NeuralFlowGrid({ regions, weights, quality }) { const key = `${fromId}\u2192${toId}`; const edgeActivity = getEdgeActivity(fromId, toId, regions, weights); const w = weights[key] ?? 0.2; - const color = REGION_INFO[fromId]?.color || '#4fa8b3'; + const color = REGION_INFO[fromId]?.color || tokens.accent; const colorArr = hexToRgbNorm(color); const isPulsing = pulsingRegions.has(fromId); diff --git a/brainsnn-r3f-app/src/components/brain/PulseWave.jsx b/brainsnn-r3f-app/src/components/brain/PulseWave.jsx index c3a2bb6..75b4e92 100644 --- a/brainsnn-r3f-app/src/components/brain/PulseWave.jsx +++ b/brainsnn-r3f-app/src/components/brain/PulseWave.jsx @@ -2,12 +2,14 @@ import React, { useRef } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import { POSITIONS, REGION_INFO } from '../../data/network'; +import { useThreeTokens } from '../../utils/threeTokens'; export default function PulseWave({ regionId, onComplete }) { const meshRef = useRef(); const progressRef = useRef(0); + const tokens = useThreeTokens(); const position = POSITIONS[regionId]; - const color = REGION_INFO[regionId]?.color || '#4fa8b3'; + const color = REGION_INFO[regionId]?.color || tokens.accent; useFrame((_, delta) => { if (!meshRef.current) return; diff --git a/brainsnn-r3f-app/src/main.jsx b/brainsnn-r3f-app/src/main.jsx index e341f67..6f7a02f 100644 --- a/brainsnn-r3f-app/src/main.jsx +++ b/brainsnn-r3f-app/src/main.jsx @@ -1,10 +1,27 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from './App'; +import NewApp from './shell/NewApp'; +import './styles/tokens.css'; import './styles/global.css'; +import './styles/shell.css'; + +// Legacy shell retired. The Claude-design AppShell (src/shell/NewApp.jsx) +// is now the only renderer. `?shell=old` is honored as a one-release +// notice that the legacy code is gone — toasts the user with a hint +// and then continues into the new shell. +if (typeof window !== 'undefined') { + try { + const url = new URLSearchParams(window.location.search); + if (url.get('shell') === 'old' || localStorage.getItem('brainsnn_shell_pref') === 'old') { + console.info('[brainsnn] legacy shell has been removed; rendering the new AppShell.'); + // Clear the stale preference so subsequent loads don't keep nagging. + try { localStorage.removeItem('brainsnn_shell_pref'); } catch { /* noop */ } + } + } catch { /* noop */ } +} ReactDOM.createRoot(document.getElementById('root')).render( - + ); diff --git a/brainsnn-r3f-app/src/shell/AppShell.jsx b/brainsnn-r3f-app/src/shell/AppShell.jsx new file mode 100644 index 0000000..8d42bb0 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/AppShell.jsx @@ -0,0 +1,218 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Topbar from './Topbar'; +import WorkspaceTabs, { WORKSPACES } from './WorkspaceTabs'; +import BrainViewport from './BrainViewport'; +import Sidebar from './Sidebar'; +import Composer from './Composer'; +import HomeWorkspace from './workspaces/HomeWorkspace'; +import AnalyzeWorkspace from './workspaces/AnalyzeWorkspace'; +import DefendWorkspace from './workspaces/DefendWorkspace'; +import BrainWorkspace from './workspaces/BrainWorkspace'; +import KnowledgeWorkspace from './workspaces/KnowledgeWorkspace'; +import TrainingWorkspace from './workspaces/TrainingWorkspace'; +import ConnectWorkspace from './workspaces/ConnectWorkspace'; +import { bus } from './bus'; + +const WORKSPACE_COMPONENT = { + home: HomeWorkspace, + analyze: AnalyzeWorkspace, + defend: DefendWorkspace, + brain: BrainWorkspace, + knowledge: KnowledgeWorkspace, + training: TrainingWorkspace, + connect: ConnectWorkspace +}; + +const VALID = new Set(Object.keys(WORKSPACE_COMPONENT)); +const STORAGE_KEY = 'brainsnn_shell_workspace'; + +// Deep-link → workspace. Each URL param is consumed by a panel that +// lives in exactly one workspace; if the user lands on a different +// workspace, only one workspace component mounts at a time and the +// target panel never runs its rehydration logic. +const PARAM_WORKSPACE = { + scan: 'defend', // CognitiveFirewallPanel + 'scan-url': 'defend', + r: 'defend', // reaction share + i: 'defend', // ImmunityPanel + a: 'defend', // AutopsyPanel + q: 'training', // QuizPanel + d: 'training', // DailyChallengePanel + x: 'training', // ComposerPanel / counter-draft + t: 'analyze', // TimeSeriesPanel + n: 'connect', // InboxPanel + v: 'connect', // DiffPanel + room: 'connect' // SessionRoomsPanel + // #state= applies brain state globally; doesn't dictate a workspace. +}; + +function initialWorkspace() { + if (typeof window === 'undefined') return 'home'; + try { + const params = new URLSearchParams(window.location.search); + // Explicit ?w= wins (lets the user override deep-link routing). + const explicit = params.get('w'); + if (explicit && VALID.has(explicit)) return explicit; + // Otherwise resolve the first matching deep-link param. + for (const [param, ws] of Object.entries(PARAM_WORKSPACE)) { + if (params.has(param)) return ws; + } + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && VALID.has(stored)) return stored; + } catch { /* noop */ } + return 'home'; +} + +/** + * AppShell — Claude-design workspace shell. + * + * Layout: + * full-width + *
+ * + * + * + * + * + * The brain viewport is mounted once and never unmounted; tab navigation + * only swaps the workspace body below it. + */ +export default function AppShell({ session, modeLabel }) { + const [workspace, setWorkspace] = useState(initialWorkspace); + + // Persist + URL-sync on workspace change. + useEffect(() => { + try { localStorage.setItem(STORAGE_KEY, workspace); } catch { /* noop */ } + try { + const url = new URL(window.location.href); + url.searchParams.set('w', workspace); + window.history.replaceState(null, '', url.toString()); + } catch { /* noop */ } + }, [workspace]); + + // Listen for cross-component workspace navigation (palette, hotkeys, + // onboarding, flashLayer). + useEffect(() => bus.on('shell:goto', ({ workspace: w }) => { + if (w && VALID.has(w)) setWorkspace(w); + }), []); + + // g+letter chord listener (gh ga gd gb gk gt gc). + useEffect(() => { + let pending = false; + let timer = null; + const onKey = (e) => { + if (e.target?.matches?.('input, textarea, [contenteditable]')) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === 'g') { + pending = true; + clearTimeout(timer); + timer = setTimeout(() => { pending = false; }, 900); + return; + } + if (pending) { + pending = false; + clearTimeout(timer); + const match = WORKSPACES.find((w) => w.chord[1] === e.key.toLowerCase()); + if (match) { + e.preventDefault(); + setWorkspace(match.id); + } + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const activeMeta = WORKSPACES.find((w) => w.id === workspace) || WORKSPACES[0]; + const promoted = workspace === 'brain'; + const workspaceHostRef = useRef(null); + + // Keep-alive: once a workspace is visited, keep it mounted so its + // panels retain their internal state (textareas, selected tabs, + // scroll, embeddings warmup, etc). Tab switch toggles visibility + // via CSS. Trades a small memory footprint (~50 KB per workspace) + // for the user expectation that tabs preserve work-in-progress. + // Lazy-loading is unaffected — each workspace's React.lazy chunk + // only fetches on first activation. + const [visited, setVisited] = useState(() => new Set([workspace])); + useEffect(() => { + setVisited((prev) => prev.has(workspace) ? prev : new Set([...prev, workspace])); + }, [workspace]); + + // Move focus to the active workspace heading on every change. + // Skips initial mount. + const didMount = useRef(false); + useEffect(() => { + if (!didMount.current) { didMount.current = true; return; } + const host = workspaceHostRef.current; + if (!host) return; + // Scope the heading lookup to the currently-visible workspace + // (others are display:none but still in the DOM). + const heading = host.querySelector(`[data-workspace="${workspace}"] .shell-workspace-header h1`); + if (heading) { + heading.setAttribute('tabindex', '-1'); + heading.focus({ preventScroll: false }); + } + }, [workspace]); + + return ( +
+ Skip to workspace content + + +
+ + +
+ + + + +
+ {Array.from(visited).map((ws) => { + const Workspace = WORKSPACE_COMPONENT[ws] || HomeWorkspace; + const isActive = ws === workspace; + return ( + + ); + })} +
+
+ + +
+
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/BrainViewport.jsx b/brainsnn-r3f-app/src/shell/BrainViewport.jsx new file mode 100644 index 0000000..06e8616 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/BrainViewport.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import BrainScene from '../components/BrainScene'; +import { REGION_INFO } from '../data/network'; + +/** + * BrainViewport — the persistent 3D brain. + * + * Mounted ONCE at the AppShell level so tab navigation never remounts + * the R3F Canvas (which costs 400-800ms). CSS classes (.is-promoted / + * .is-strip) control whether the viewer takes the whole upper region + * (BRAIN workspace) or compresses to a thin always-visible strip. + * + * Do NOT import BrainScene from anywhere else inside the shell. + */ +export default function BrainViewport({ + state, + quality, + knowledgeMode, + affectOverride, + promoted, + onSelect, + onQualityChange, + modeLabel +}) { + const stats = { + mean: state.mean ?? Object.values(state.regions).reduce((a, v) => a + v, 0) / Object.keys(state.regions).length, + plasticity: state.plasticity ?? Object.values(state.weights).reduce((a, v) => a + v, 0) / Object.keys(state.weights).length + }; + + return ( +
+
+ Tick {state.tick} + Mean {stats.mean.toFixed(3)} + Plasticity {stats.plasticity.toFixed(3)} + {state.selected} + Q · {quality} + {modeLabel} + {promoted && state.selected && REGION_INFO[state.selected] && ( + {REGION_INFO[state.selected].name} + )} +
+ +
+ +
+
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/Composer.jsx b/brainsnn-r3f-app/src/shell/Composer.jsx new file mode 100644 index 0000000..b97bcc3 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/Composer.jsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { bus } from './bus'; + +/** + * Composer — persistent top input. Paste text or drop a file; the active + * workspace decides how to consume the payload. Emits `shell:compose` + * with { text, mode } when the user submits; workspaces subscribe. + * + * Designed as a single-line ambient input — for the heavy stuff users + * still go into the relevant panel's own textarea. + * + * Takes no props so re-renders are entirely state-internal — memoize + * the export so AppShell's simulation-tick re-renders skip past it. + */ +function ComposerImpl() { + const [text, setText] = useState(''); + const [mode, setMode] = useState('scan'); + + // Each mode routes to the workspace that knows how to handle it. + const MODE_WORKSPACE = { + scan: 'defend', + autopsy: 'defend', + diff: 'connect', + rag: 'knowledge' + }; + + const submit = () => { + const trimmed = text.trim(); + if (!trimmed) return; + const ws = MODE_WORKSPACE[mode]; + if (ws) bus.emit('shell:goto', { workspace: ws }); + // requestAnimationFrame so the target workspace mounts before + // the compose event arrives at its subscribers. + requestAnimationFrame(() => bus.emit('shell:compose', { text: trimmed, mode })); + // Don't clear — let the user iterate. + }; + + const onKey = (e) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + submit(); + } + }; + + return ( +
+ + setText(e.target.value)} + onKeyDown={onKey} + placeholder="Paste text to scan, route through autopsy, diff, or ask the brain… (⌘+Enter)" + /> + +
+ ); +} + +export default React.memo(ComposerImpl); diff --git a/brainsnn-r3f-app/src/shell/NewApp.jsx b/brainsnn-r3f-app/src/shell/NewApp.jsx new file mode 100644 index 0000000..8e6bcf8 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/NewApp.jsx @@ -0,0 +1,569 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import AppShell from './AppShell'; +import ToastContainer from '../components/ToastContainer'; +import KeyboardHelp from '../components/KeyboardHelp'; +import OnboardingWalkthrough from '../components/OnboardingWalkthrough'; +import CommandPalette from '../components/CommandPalette'; +import ErrorBoundary from '../components/ErrorBoundary'; +import UpdateBanner from './UpdateBanner'; + +import { decodeStateFromHash } from '../components/SharePanel'; +import { decodeReaction } from '../utils/reactionCard'; +import { + mapTRIBEToRegions, + setActiveRules, + resetActiveRules, + deserializeRules +} from '../utils/cognitiveFirewall'; +import { registerBridgeContext, logToolCall } from '../utils/mcpBridge'; +import { recordEvent as recordImmunity, IMMUNITY_EVENTS } from '../utils/immunityScore'; +import { + applyScenario, + createInitialState, + resetState, + simulateStep +} from '../utils/sim'; +import { + applyMockEEG, + connectMuseEEG, + connectSerialEEG, + mapEEGToRegions, + parseMusePacket +} from '../utils/eeg'; +import { startCanvasRecording } from '../utils/recording'; +import { listSnapshots, loadSnapshot, saveSnapshot } from '../utils/snapshots'; +import { + registerDreamProviders, + startDreamMonitor, + stopDreamMonitor, + markActivity +} from '../utils/dreamMode'; +import { mapRagToRegions } from '../utils/neuroRag'; +import { mapMultimodalToRegions } from '../utils/multimodalRag'; +import { mapFusedToRegions } from '../utils/vectorGraphFusion'; +import { applyAffectsToBrainState } from '../utils/affectiveDecoder'; +import { applyNTBath } from '../utils/neurochemistry'; +import { registerShortcuts } from '../utils/shortcuts'; +import { trendDirection } from '../utils/analytics'; +import { toastSuccess, toastInfo, toastWarning } from '../utils/toastStore'; +import { registerServiceWorker } from '../utils/pwa'; +import { registerTheme } from '../utils/theme'; + +/** + * NewApp — Claude-design AppShell wrapper. + * + * Mirrors all state, refs, effects, and onApply* callbacks from + * legacy App.jsx so the new shell shares identical behavior. Both + * shells must NEVER mount simultaneously (main.jsx picks one via + * ?shell=new) — they'd double-register MCP bridge + dream monitor. + */ +export default function NewApp() { + const [state, setState] = useState(() => { + const shared = decodeStateFromHash(); + if (shared) { + const initial = createInitialState(); + return { + ...initial, + regions: { ...initial.regions, ...shared.regions }, + weights: { ...initial.weights, ...(shared.weights || {}) }, + scenario: shared.scenario || initial.scenario, + selected: shared.selected || initial.selected, + tick: shared.tick || 0 + }; + } + return createInitialState(); + }); + + const [mode, setMode] = useState('simulation'); + const [eegStatus, setEegStatus] = useState({ connected: false, label: 'No device connected' }); + const [timelineIndex, setTimelineIndex] = useState(0); + const [isRecording, setIsRecording] = useState(false); + const [exportStatus, setExportStatus] = useState('idle'); + const [exportProgress, setExportProgress] = useState(0); + const [quality, setQuality] = useState('high'); + const [gifOptions, setGifOptions] = useState({ trimStart: 0, trimDuration: 2.5, fps: 12, width: 720 }); + const [showKbHelp, setShowKbHelp] = useState(false); + const [firewallResult, setFirewallResult] = useState(null); + const [knowledgeMode, setKnowledgeMode] = useState(false); + const [affectOverride, setAffectOverride] = useState(null); + const [lastAffectDecode, setLastAffectDecode] = useState(null); + + const [incomingImmunity] = useState(() => { + try { return new URLSearchParams(window.location.search).get('i') || null; } catch { return null; } + }); + const [incomingAutopsy] = useState(() => { + try { return new URLSearchParams(window.location.search).get('a') || null; } catch { return null; } + }); + const [incomingDaily] = useState(() => { + try { return new URLSearchParams(window.location.search).get('d') || null; } catch { return null; } + }); + const [initialFirewallScan] = useState(() => { + try { + const hash = new URLSearchParams(window.location.search).get('r'); + if (!hash) return null; + const reaction = decodeReaction(hash); + if (!reaction || !reaction.text) return null; + return { + text: reaction.text, + result: { + emotionalActivation: reaction.emotionalActivation, + cognitiveSuppression: reaction.cognitiveSuppression, + manipulationPressure: reaction.manipulationPressure, + trustErosion: reaction.trustErosion, + evidence: [], + confidence: 'shared', + recommendedAction: 'Rehydrated from shared reaction link.', + source: 'shared-link' + }, + autoApply: true + }; + } catch { return null; } + }); + + const recorderRef = useRef(null); + const historyRef = useRef([]); + const stateRef = useRef(state); + stateRef.current = state; + + // History tracking for analytics trends. + useEffect(() => { + historyRef.current.push({ regions: { ...state.regions } }); + if (historyRef.current.length > 60) historyRef.current.shift(); + }, [state.tick]); + + // PWA + theme on mount. + useEffect(() => { registerServiceWorker(); registerTheme(); }, []); + + // MCP bridge context. + useEffect(() => { + registerBridgeContext({ + getState: () => stateRef.current, + setState, + getHistory: () => historyRef.current, + applyScenarioKey: (key) => setState((s) => applyScenario(s, key)), + triggerBurst: () => setState((s) => ({ ...s, burst: 20, scenario: 'MCP Sensory Burst' })), + resetBrain: () => setState(resetState()), + onToolCall: (entry) => logToolCall(entry) + }); + }, []); + + // Dream mode. + useEffect(() => { + registerDreamProviders({ + getSnapshots: () => listSnapshots().slice(0, 10).map((s) => loadSnapshot(s.id)).filter(Boolean), + setState, + narrate: (text) => toastInfo(text) + }); + startDreamMonitor(); + return () => stopDreamMonitor(); + }, []); + + const trends = useMemo(() => { + const t = {}; + for (const key of Object.keys(state.regions)) { + const values = historyRef.current.map((h) => h.regions?.[key] ?? 0); + t[key] = trendDirection(values); + } + return t; + }, [state.tick, state.regions]); + + // Simulation tick. + useEffect(() => { + if (mode !== 'simulation') return; + const id = setInterval(() => setState((prev) => simulateStep(prev)), 180); + return () => clearInterval(id); + }, [mode]); + + useEffect(() => { + setTimelineIndex(Math.max(0, state.history.length - 1)); + }, [state.history.length]); + + const timelineFrame = state.history[timelineIndex] ?? state.history[state.history.length - 1]; + + // Keyboard shortcuts. + useEffect(() => { + const QUALITY_CYCLE = ['low', 'high', 'ultra']; + return registerShortcuts({ + toggleRun: () => setState((s) => ({ ...s, running: !s.running })), + burst: () => { setState((s) => ({ ...s, burst: 20, scenario: 'Sensory Burst' })); toastInfo('Sensory burst triggered'); }, + reset: () => { setState(resetState()); setMode('simulation'); toastInfo('Brain state reset'); }, + modeSimulation: () => { setMode('simulation'); toastInfo('Switched to Simulation mode'); }, + modeTribe: () => { setMode('tribe'); toastInfo('Switched to TRIBE v2 mode'); }, + modeEeg: () => { setMode('eeg'); toastInfo('Switched to EEG mode'); }, + snapshot: () => { saveSnapshot(state); toastSuccess('Snapshot saved'); }, + record: () => { + const canvas = document.querySelector('canvas'); + if (!canvas) return; + if (!isRecording) { + try { + recorderRef.current = startCanvasRecording(canvas, { onStatus: setExportStatus, onProgress: setExportProgress }); + setIsRecording(true); + toastInfo('Recording started'); + } catch (err) { setExportStatus(err.message); } + } else if (recorderRef.current) { + recorderRef.current.stop(); + recorderRef.current = null; + setIsRecording(false); + setExportStatus('WebM ready'); + setExportProgress(100); + toastSuccess('Recording saved'); + } + }, + cycleQuality: () => { + setQuality((prev) => { + const idx = QUALITY_CYCLE.indexOf(prev); + const next = QUALITY_CYCLE[(idx + 1) % QUALITY_CYCLE.length]; + toastInfo(`Quality: ${next}`); + return next; + }); + }, + showHelp: () => setShowKbHelp(true) + }); + }, [state, isRecording]); + + // ---------- handlers ---------- + const applyTribeFrame = useCallback((frame) => { + if (!frame?.regions) return; + setState((prev) => { + const regions = { ...prev.regions, ...frame.regions }; + const mean = Object.values(regions).reduce((a, v) => a + v, 0) / Object.keys(regions).length; + const plasticity = Object.values(prev.weights).reduce((a, v) => a + v, 0) / Object.keys(prev.weights).length; + return { + ...prev, + regions, + tick: prev.tick + 1, + scenario: frame.scenario || 'TRIBE v2', + history: [...prev.history.slice(-39), { mean, plasticity }], + mean, + plasticity + }; + }); + }, []); + + const onSelectRegion = useCallback((id) => { + setState((s) => ({ ...s, selected: id || s.selected })); + }, []); + + const onApplyFirewall = useCallback((result) => { + setFirewallResult(result); + setState((s) => mapTRIBEToRegions(s, result)); + recordImmunity(IMMUNITY_EVENTS.FIREWALL_SCAN, { + pressure: result.manipulationPressure, + confidence: result.confidence + }); + toastWarning(`Firewall: ${result.recommendedAction.slice(0, 60)}...`); + }, []); + + const onApplyGemma = useCallback((result) => { + setFirewallResult(result); + setState((s) => mapTRIBEToRegions(s, result)); + recordImmunity(IMMUNITY_EVENTS.GEMMA_SCAN, { pressure: result.manipulationPressure }); + toastInfo('Gemma 4 analysis applied to brain'); + }, []); + + const onApplyGemini = useCallback((result) => { + setFirewallResult(result); + setState((s) => mapTRIBEToRegions(s, result)); + recordImmunity(IMMUNITY_EVENTS.GEMMA_SCAN, { pressure: result.manipulationPressure }); + toastInfo('Gemini analysis applied to brain'); + }, []); + + const onRestoreSnapshot = useCallback((snap) => { + setState((prev) => ({ + ...prev, + regions: { ...prev.regions, ...snap.regions }, + weights: { ...prev.weights, ...snap.weights }, + selected: snap.selected || prev.selected, + scenario: `Restored: ${snap.name}`, + tick: prev.tick + 1 + })); + recordImmunity(IMMUNITY_EVENTS.SNAPSHOT_SAVED, { name: snap.name }); + toastSuccess(`Restored: ${snap.name}`); + }, []); + + const onApplyKnowledge = useCallback((kbState) => { + setState((prev) => ({ + ...prev, + regions: { ...prev.regions, ...kbState.regions }, + weights: { ...prev.weights, ...kbState.weights }, + scenario: kbState.scenario || 'Knowledge Brain', + tick: prev.tick + 1 + })); + setKnowledgeMode(true); + recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, {}); + toastSuccess('Knowledge map applied — 3D brain now shows knowledge domains'); + }, []); + + const onApplyCodeBrain = useCallback((payload) => { + setState((prev) => ({ + ...prev, + regions: { ...prev.regions, ...payload.regions }, + scenario: payload.scenario, + tick: prev.tick + 1 + })); + recordImmunity(IMMUNITY_EVENTS.CODE_ANALYZED, {}); + toastSuccess('Code communities mapped onto brain'); + }, []); + + const onApplyConversation = useCallback((payload) => { + setState((prev) => ({ + ...prev, + regions: { ...prev.regions, ...payload.regions }, + scenario: payload.scenario, + tick: prev.tick + 1 + })); + recordImmunity(IMMUNITY_EVENTS.CONVERSATION_ANALYZED, { + pressureAvg: payload.pressureAvg, + turns: payload.turns + }); + toastSuccess('Conversation final state applied to brain'); + }, []); + + const onApplyRag = useCallback((ragResult) => { + markActivity(); + setState((prev) => mapRagToRegions(prev, ragResult)); + recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { + name: `RAG: ${ragResult.results.length} hits · ${ragResult.mode}` + }); + toastSuccess(`Retrieved ${ragResult.results.length} chunks · ${ragResult.mode}`); + }, []); + + const onApplyMultimodalRag = useCallback((mmResult) => { + markActivity(); + setState((prev) => mapMultimodalToRegions(prev, mmResult)); + const histLabel = Object.entries(mmResult.byModality || {}).map(([k, v]) => `${k}:${v}`).join(' '); + recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { + name: `Multimodal RAG: ${mmResult.results.length} hits · ${histLabel}` + }); + toastSuccess(`Retrieved ${mmResult.results.length} items · ${mmResult.mode}`); + }, []); + + const onApplyFusedRag = useCallback((fusedResult) => { + markActivity(); + setState((prev) => mapFusedToRegions(prev, fusedResult)); + const stats = fusedResult.fusionStats || {}; + recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { + name: `Fused RAG: ${fusedResult.results.length} hits · ${stats.siblingPulls || 0} siblings` + }); + toastSuccess(`Fused ${fusedResult.results.length} items · ${fusedResult.mode}`); + }, []); + + const onApplyAffect = useCallback((map) => { + markActivity(); + setAffectOverride(map); + if (map) toastSuccess(`Affect colors applied to ${Object.keys(map).length} regions`); + else toastInfo('Affect colors cleared'); + }, []); + + const onApplyAffectActivation = useCallback((decoded) => { + markActivity(); + setLastAffectDecode(decoded); + setState((prev) => applyAffectsToBrainState(prev, decoded)); + recordImmunity(IMMUNITY_EVENTS.AFFECT_DECODED, { + name: `Affects: ${decoded.dominant.map((d) => d.label).join(', ') || 'neutral'}` + }); + toastInfo('Affect activation nudged into brain state'); + }, []); + + const onApplyNT = useCallback((levels, opts) => { + markActivity(); + setState((prev) => applyNTBath(prev, levels, opts)); + toastSuccess(`Applied ${opts?.label ?? 'NT bath'} to brain`); + }, []); + + const onPromoteEvolve = useCallback((node) => { + markActivity(); + if (!node?.ruleSet) { + resetActiveRules(); + toastInfo('Firewall reset to default rules'); + return; + } + const rules = deserializeRules(node.ruleSet); + setActiveRules(rules); + const f1 = node.results?.summary?.thresholds?.[0.3]?.f1 ?? 0; + recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { name: `Evolve promoted: F1 ${f1.toFixed(3)}` }); + toastSuccess(`Evolved firewall promoted (F1 ${f1.toFixed(3)})`); + }, []); + + const onPromoteAttack = useCallback((node, category) => { + markActivity(); + if (!node) { + toastInfo('Red team corpus reset to defaults'); + return; + } + const evasion = ((1 - (node.detection || 0)) * 100).toFixed(0); + recordImmunity(IMMUNITY_EVENTS.KNOWLEDGE_SCANNED, { name: `Attack promoted (${category}): ${evasion}% evasion` }); + toastSuccess(`Evolved attack added to "${category}" — ${evasion}% evasion`); + }, []); + + const onApplyPluginResults = useCallback((combined) => { + const mapped = { + emotionalActivation: combined.negativity ?? combined.emotionalActivation ?? 0, + cognitiveSuppression: combined.complexity ?? combined.cognitiveSuppression ?? 0, + manipulationPressure: 1 - (combined.credibility ?? 0.5), + trustErosion: combined.negativity ?? combined.trustErosion ?? 0 + }; + setState((s) => mapTRIBEToRegions(s, mapped)); + toastInfo('Plugin analysis applied to brain'); + }, []); + + const onRemoteState = useCallback((remote) => { + if (remote?.regions) { + setState((prev) => ({ + ...prev, + regions: { ...prev.regions, ...remote.regions }, + scenario: remote.scenario || 'Remote Sync', + tick: prev.tick + 1 + })); + } + }, []); + + // Controls bar callbacks bundle. + const controls = useMemo(() => ({ + onSetMode: setMode, + onSetQuality: setQuality, + onToggleRun: () => { markActivity(); setState((s) => ({ ...s, running: !s.running })); }, + onBurst: () => { markActivity(); setState((s) => ({ ...s, burst: 20, scenario: 'Sensory Burst' })); }, + onReset: () => { markActivity(); setState(resetState()); setMode('simulation'); }, + onScenario: (key) => { markActivity(); setState((s) => applyScenario(s, key)); setMode('simulation'); }, + onToggleRecording: () => { + const canvas = document.querySelector('canvas'); + if (!canvas) return; + if (!isRecording) { + try { + recorderRef.current = startCanvasRecording(canvas, { onStatus: setExportStatus, onProgress: setExportProgress }); + setIsRecording(true); + } catch (err) { setExportStatus(err.message); } + } else if (recorderRef.current) { + recorderRef.current.stop(); + recorderRef.current = null; + setIsRecording(false); + setExportStatus('WebM ready'); + setExportProgress(100); + } + }, + onExportGif: async () => { + const canvas = document.querySelector('canvas'); + if (!canvas) return; + try { + if (!recorderRef.current) { + recorderRef.current = startCanvasRecording(canvas, { onStatus: setExportStatus, onProgress: setExportProgress }); + setIsRecording(true); + setTimeout(async () => { + if (recorderRef.current) { + await recorderRef.current.convertToGif(gifOptions); + recorderRef.current = null; + setIsRecording(false); + } + }, Math.max(1200, gifOptions.trimDuration * 1000)); + } else { + await recorderRef.current.convertToGif(gifOptions); + recorderRef.current = null; + setIsRecording(false); + } + } catch (err) { setExportStatus(err.message); } + } + }), [isRecording, gifOptions]); + + // Demo tiles play-through. + const onDemo = useCallback(({ text, result }) => { + setFirewallResult(result); + setState((s) => mapTRIBEToRegions(s, result)); + recordImmunity(IMMUNITY_EVENTS.FIREWALL_SCAN, { + pressure: result.manipulationPressure, + confidence: result.confidence + }); + toastInfo('Demo scan applied — watch the brain react'); + }, []); + + // EEG handlers. + const onConnectMuse = useCallback(async () => { + try { + const result = await connectMuseEEG(); + setEegStatus({ connected: true, label: `Connected via Bluetooth: ${result.deviceName}` }); + setMode('eeg'); + const mockPacket = new DataView(new Uint8Array([1, 20, 0, 120, 0, 90, 0, 60]).buffer); + const parsed = parseMusePacket(mockPacket); + setState((s) => mapEEGToRegions(s, parsed)); + toastSuccess('Muse EEG connected'); + } catch (err) { + setEegStatus({ connected: false, label: err.message }); + toastWarning('EEG connection failed'); + } + }, []); + + const onConnectSerial = useCallback(async () => { + try { + await connectSerialEEG(); + setEegStatus({ connected: true, label: 'Serial EEG bridge connected' }); + setMode('eeg'); + toastSuccess('Serial EEG connected'); + } catch (err) { + setEegStatus({ connected: false, label: err.message }); + } + }, []); + + const onInjectMockEeg = useCallback(() => { + setState((s) => applyMockEEG(s)); + setEegStatus({ connected: true, label: 'Mock EEG injected into THL/PFC/HPC' }); + setMode('eeg'); + toastInfo('Mock EEG data injected'); + }, []); + + const onGifChange = useCallback((key, value) => { + setGifOptions((prev) => ({ ...prev, [key]: value })); + }, []); + + const onReplayBurst = useCallback(() => { + setState((s) => ({ ...s, burst: 20, tick: 0, scenario: 'Replay Burst' })); + }, []); + + // Stable identity so React.memo'd Topbar can short-circuit re-renders + // on every simulation tick. + const onShowHelp = useCallback(() => setShowKbHelp(true), []); + + const modeLabel = mode === 'tribe' ? 'TRIBE v2' : mode === 'eeg' ? 'Live EEG' : 'Simulation'; + + const session = { + // state + state, mode, quality, knowledgeMode, affectOverride, lastAffectDecode, + firewallResult, trends, timelineFrame, timelineIndex, + isRecording, exportStatus, exportProgress, gifOptions, eegStatus, + // url rehydration + initialFirewallScan, incomingImmunity, incomingAutopsy, incomingDaily, + // setters used directly + onScrubTimeline: setTimelineIndex, + onSelectRegion, + onShowHelp, + onGifChange, + onReplayBurst, + // bundles + onControls: controls, + onDemo, + // brain mutations + onApplyFirewall, onApplyGemma, onApplyGemini, + onApplyKnowledge, onApplyCodeBrain, onApplyConversation, + onApplyRag, onApplyMultimodalRag, onApplyFusedRag, + onApplyAffect, onApplyAffectActivation, onDecodeAffect: setLastAffectDecode, + onApplyNT, onApplyTribeFrame: applyTribeFrame, onApplyPluginResults, + onPromoteEvolve, onPromoteAttack, + onRestoreSnapshot, onRemoteState, + // mode wiring + onSetMode: setMode, + // EEG + onConnectMuse, onConnectSerial, onInjectMockEeg + }; + + return ( +
+
+ + setShowKbHelp(false)} /> + + + + + +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/Sidebar.jsx b/brainsnn-r3f-app/src/shell/Sidebar.jsx new file mode 100644 index 0000000..4352b34 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/Sidebar.jsx @@ -0,0 +1,37 @@ +import React, { Suspense, lazy } from 'react'; +import InspectorPanel from '../components/InspectorPanel'; + +const RoleTourPanel = lazy(() => import('../components/RoleTourPanel')); +const MilestonePanel = lazy(() => import('../components/MilestonePanel')); + +/** + * Sidebar — right rail. InspectorPanel always visible (it's the persistent + * context for whichever region is selected). Below it, a collapsible role + * tour + milestone widget so first-time users can find their way without + * those panels needing dedicated workspace slots. + */ +export default function Sidebar({ state, timelineFrame, onSelect }) { + return ( +
}> + + + + +
+ Layer atlas + Loading…
}> + + + + + ); +} diff --git a/brainsnn-r3f-app/src/shell/Topbar.jsx b/brainsnn-r3f-app/src/shell/Topbar.jsx new file mode 100644 index 0000000..82eb267 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/Topbar.jsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from 'react'; +import { bus } from './bus'; +import { getTheme, setTheme } from '../utils/theme'; + +const THEME_CYCLE = ['auto', 'dark', 'light']; + +/** + * Topbar — Claude.ai-style minimal header. + * Left: wordmark + active workspace breadcrumb. + * Right: theme toggle, ⌘K hint, keyboard help. + */ +function TopbarImpl({ workspace, firewallResult, immunity, onShowHelp }) { + // Mirror the canonical theme state (theme.js owns persistence under + // brainsnn_theme_v1 + applies CSS vars + broadcasts cross-tab). + // Subscribing to BroadcastChannel via theme.js's registerTheme() + // handler isn't necessary here — we just re-read on mount and on + // every cycle. Cross-tab updates land via dataset mutations which + // we observe via getTheme() lazily. + const [theme, setLocalTheme] = useState(() => getTheme().theme); + + // Re-read in case another tab flipped the theme since mount. + useEffect(() => { + const handler = () => setLocalTheme(getTheme().theme); + window.addEventListener('focus', handler); + return () => window.removeEventListener('focus', handler); + }, []); + + const cycleTheme = () => { + const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]; + const current = getTheme(); + setTheme({ ...current, theme: next }); // applies CSS + broadcasts cross-tab + setLocalTheme(next); + }; + + const openPalette = () => bus.emit('shell:palette-open'); + + // Optional ambient indicator — last firewall pressure pill. + const pressure = firewallResult?.manipulationPressure; + const pressureLabel = typeof pressure === 'number' + ? `${Math.round(pressure * 100)}%` + : null; + + return ( +
+
+
BrainSNN
+ · + {workspace} +
+ +
+ {pressureLabel && ( + + {pressureLabel} + + )} + {typeof immunity === 'number' && ( + + 🛡 {Math.round(immunity)} + + )} + + + +
+
+ ); +} + +// Topbar's props change only when the user runs a scan (firewallResult), +// switches workspace (label), or earns immunity — not every simulation +// tick. React.memo gates out the cascade. onShowHelp is the only +// inline lambda; if it gets de-stabilized later, memoize at the caller. +export default React.memo(TopbarImpl); diff --git a/brainsnn-r3f-app/src/shell/UpdateBanner.jsx b/brainsnn-r3f-app/src/shell/UpdateBanner.jsx new file mode 100644 index 0000000..1ede2ca --- /dev/null +++ b/brainsnn-r3f-app/src/shell/UpdateBanner.jsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react'; +import { startSwUpdateWatch, subscribeSwUpdate, activateNewSw } from '../utils/swUpdate'; + +/** + * UpdateBanner — small chip in the bottom-right that appears when a + * new service worker is waiting. Click "Reload" to skipWaiting + boot + * against the new chunks. Dismissible until the next update. + */ +export default function UpdateBanner() { + const [waiting, setWaiting] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + startSwUpdateWatch(); + return subscribeSwUpdate((state) => { + if (state.status === 'waiting') { + setWaiting(true); + setDismissed(false); // re-show after each new update + } + }); + }, []); + + if (!waiting || dismissed) return null; + + return ( +
+ A new version is ready. + + +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx new file mode 100644 index 0000000..c55f2cd --- /dev/null +++ b/brainsnn-r3f-app/src/shell/WorkspaceTabs.jsx @@ -0,0 +1,69 @@ +import React, { useRef } from 'react'; + +export const WORKSPACES = [ + { id: 'home', label: 'Home', chord: 'gh', glyph: '◉' }, + { id: 'analyze', label: 'Analyze', chord: 'ga', glyph: '◯' }, + { id: 'defend', label: 'Defend', chord: 'gd', glyph: '◇' }, + { id: 'brain', label: 'Brain', chord: 'gb', glyph: '◐' }, + { id: 'knowledge', label: 'Knowledge', chord: 'gk', glyph: '◑' }, + { id: 'training', label: 'Training', chord: 'gt', glyph: '◒' }, + { id: 'connect', label: 'Connect', chord: 'gc', glyph: '◓' } +]; + +function WorkspaceTabsImpl({ active, onChange }) { + const ref = useRef(null); + const activeIdx = WORKSPACES.findIndex((w) => w.id === active); + + function onKeyDown(e) { + const idx = WORKSPACES.findIndex((w) => w.id === active); + if (idx < 0) return; + let next = idx; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = (idx + 1) % WORKSPACES.length; + else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = (idx - 1 + WORKSPACES.length) % WORKSPACES.length; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = WORKSPACES.length - 1; + else return; + e.preventDefault(); + onChange(WORKSPACES[next].id); + // Move focus to the newly active tab so the user can keep arrowing. + requestAnimationFrame(() => { + const btns = ref.current?.querySelectorAll('[role="tab"]'); + btns?.[next]?.focus(); + }); + } + + return ( + + ); +} + +// Tabs depend only on { active, onChange }. AppShell re-renders every +// 180ms (simulation tick) but neither of these change with it. +export default React.memo(WorkspaceTabsImpl); diff --git a/brainsnn-r3f-app/src/shell/bus.js b/brainsnn-r3f-app/src/shell/bus.js new file mode 100644 index 0000000..445c0de --- /dev/null +++ b/brainsnn-r3f-app/src/shell/bus.js @@ -0,0 +1,85 @@ +/** + * bus — tiny global event bus for the AppShell. + * + * Used by Workspace navigation (`shell:goto`), command palette / hotkeys + * (`shell:flash`), onboarding (`shell:goto` before highlighting), and + * any panel that needs to communicate cross-workspace without prop + * drilling. + * + * Intentionally ~50 LOC and dependency-free. Replaces the scattered + * window.dispatchEvent / addEventListener patterns the legacy code uses. + */ + +const _listeners = new Map(); + +// Sticky-event buffer. The composer emits `shell:compose` before the +// destination workspace's lazy chunk has finished loading; without +// this, the event is fired into the void and the panel never sees +// it (silent drop). Sticky events are stored with a timestamp and +// replayed to late subscribers if still inside the TTL window. +const _sticky = new Map(); +const STICKY_EVENTS = { + 'shell:compose': 2500 // ms — covers a cold lazy-chunk fetch +}; + +export const bus = { + on(event, handler) { + if (!_listeners.has(event)) _listeners.set(event, new Set()); + _listeners.get(event).add(handler); + + // Late-subscriber replay for sticky events. + const ttl = STICKY_EVENTS[event]; + if (ttl != null) { + const buffered = _sticky.get(event); + if (buffered && Date.now() - buffered.ts <= ttl) { + // Defer one microtask so the subscriber's component has + // finished mounting before its handler runs. + Promise.resolve().then(() => { + try { handler(buffered.payload); } catch (err) { + console.error(`[bus] sticky replay for "${event}" threw:`, err); + } + }); + } + } + + return () => bus.off(event, handler); + }, + + off(event, handler) { + const set = _listeners.get(event); + if (!set) return; + set.delete(handler); + if (set.size === 0) _listeners.delete(event); + }, + + emit(event, payload) { + // Stash sticky events so late subscribers (lazy workspaces) can + // pick them up after the cold chunk fetch resolves. + if (event in STICKY_EVENTS) { + _sticky.set(event, { payload, ts: Date.now() }); + } + const set = _listeners.get(event); + if (!set) return; + // Snapshot so handlers can off() themselves without breaking iteration. + for (const fn of Array.from(set)) { + try { fn(payload); } catch (err) { + console.error(`[bus] handler for "${event}" threw:`, err); + } + } + }, + + /** + * Clear a sticky event so a stale value doesn't replay to a + * subscriber that mounts much later. Used by the composer after + * its event has been seen (or after a workspace switch). + */ + clearSticky(event) { + _sticky.delete(event); + } +}; + +// React hook helper — auto-cleanup on unmount. +import { useEffect } from 'react'; +export function useBusEvent(event, handler) { + useEffect(() => bus.on(event, handler), [event, handler]); +} diff --git a/brainsnn-r3f-app/src/shell/workspaces/AnalyzeWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/AnalyzeWorkspace.jsx new file mode 100644 index 0000000..8f28d9f --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/AnalyzeWorkspace.jsx @@ -0,0 +1,36 @@ +import React, { Suspense, lazy } from 'react'; +import ErrorBoundary from '../../components/ErrorBoundary'; +import AnalyticsDashboard from '../../components/AnalyticsDashboard'; +import NarrativePanel from '../../components/NarrativePanel'; +import ActivityCharts from '../../components/ActivityCharts'; + +const TimeSeriesPanel = lazy(() => import('../../components/TimeSeriesPanel')); +const CalendarHeatmapPanel = lazy(() => import('../../components/CalendarHeatmapPanel')); +const HeatmapTimeline = lazy(() => import('../../components/HeatmapTimeline')); +const ComparatorPanel = lazy(() => import('../../components/ComparatorPanel')); +const DrillDownPanel = lazy(() => import('../../components/DrillDownPanel')); + +export default function AnalyzeWorkspace({ session }) { + const { state, trends, firewallResult } = session; + return ( +
+
+
Analyze
+

Read the brain.

+

Live signals, trends, anomalies, and how today's activity stacks up.

+
+ + + + + + Loading…
}> + + + + + + +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/workspaces/BrainWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/BrainWorkspace.jsx new file mode 100644 index 0000000..bbdedc6 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/BrainWorkspace.jsx @@ -0,0 +1,79 @@ +import React, { Suspense, lazy } from 'react'; +import ErrorBoundary from '../../components/ErrorBoundary'; + +const SplitBrainView = lazy(() => import('../../components/SplitBrainView')); +const AffectiveDecoderPanel = lazy(() => import('../../components/AffectiveDecoderPanel')); +const DreamModePanel = lazy(() => import('../../components/DreamModePanel')); +const OscillationsPanel = lazy(() => import('../../components/OscillationsPanel')); +const ConversationBrainPanel = lazy(() => import('../../components/ConversationBrainPanel')); +const NeurochemistryPanel = lazy(() => import('../../components/NeurochemistryPanel')); +const ToneShifterPanel = lazy(() => import('../../components/ToneShifterPanel')); +const MCPBridgePanel = lazy(() => import('../../components/MCPBridgePanel')); +const BrainStewardPanel = lazy(() => import('../../components/BrainStewardPanel')); +const TribePanel = lazy(() => import('../../components/TribePanel')); +const EEGPanel = lazy(() => import('../../components/EEGPanel')); +const SnapshotPanel = lazy(() => import('../../components/SnapshotPanel')); + +export default function BrainWorkspace({ session }) { + const { + state, quality, lastAffectDecode, + onApplyAffect, onApplyAffectActivation, onDecodeAffect, + onApplyNT, onApplyTribeFrame, onSetMode, mode, + onConnectMuse, onConnectSerial, onInjectMockEeg, eegStatus, + onRestoreSnapshot + } = session; + + return ( +
+
+
Brain
+

Shape what fires.

+

Snapshots, affects, neurochemistry, dreams — and the agent surfaces that drive the brain from outside the browser.

+
+ + Loading…
}> + + + + + + + +
+ Mind state + + + + +
+ +
+ Snapshots & comparisons + + +
+ +
+ External drivers + + + + + + + + +
+ +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx new file mode 100644 index 0000000..42359c6 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/ConnectWorkspace.jsx @@ -0,0 +1,120 @@ +import React, { Suspense, lazy, useState } from 'react'; +import ErrorBoundary from '../../components/ErrorBoundary'; + +const SharePanel = lazy(() => import('../../components/SharePanel')); +const SessionRoomsPanel = lazy(() => import('../../components/SessionRoomsPanel')); +const ExportPanel = lazy(() => import('../../components/ExportPanel')); +const LiveSyncPanel = lazy(() => import('../../components/LiveSyncPanel')); +const SyncPanel = lazy(() => import('../../components/SyncPanel')); +const PluginPanel = lazy(() => import('../../components/PluginPanel')); +const VoiceControl = lazy(() => import('../../components/VoiceControl')); +const DiffPanel = lazy(() => import('../../components/DiffPanel')); +const InboxPanel = lazy(() => import('../../components/InboxPanel')); +const JournalismPanel = lazy(() => import('../../components/JournalismPanel')); +const PortabilityPanel = lazy(() => import('../../components/PortabilityPanel')); +const PrivacyBudgetPanel = lazy(() => import('../../components/PrivacyBudgetPanel')); +const ExtensionPanel = lazy(() => import('../../components/ExtensionPanel')); +const ScanAnywherePanel = lazy(() => import('../../components/ScanAnywherePanel')); +const OcrPanel = lazy(() => import('../../components/OcrPanel')); +const AudioPanel = lazy(() => import('../../components/AudioPanel')); +const ApiDocsPanel = lazy(() => import('../../components/ApiDocsPanel')); +const LobsterTrapPanel = lazy(() => import('../../components/LobsterTrapPanel')); +const CommunityPackPanel = lazy(() => import('../../components/CommunityPackPanel')); +const PwaInstallPanel = lazy(() => import('../../components/PwaInstallPanel')); +const TimelinePanel = lazy(() => import('../../components/TimelinePanel')); +const ThemePanel = lazy(() => import('../../components/ThemePanel')); +const CapabilitiesPanel = lazy(() => import('../../components/CapabilitiesPanel')); +const RoleTourPanel = lazy(() => import('../../components/RoleTourPanel')); +const MilestonePanel = lazy(() => import('../../components/MilestonePanel')); + +const SUB_TABS = [ + { id: 'share', label: 'Share' }, + { id: 'external', label: 'External' }, + { id: 'system', label: 'System' }, + { id: 'discover', label: 'Discover' } +]; + +export default function ConnectWorkspace({ session }) { + const [tab, setTab] = useState('share'); + const { state, isRecording, exportStatus, exportProgress, gifOptions, onGifChange, onRemoteState, timelineFrame, timelineIndex, onScrubTimeline, onReplayBurst, onApplyPluginResults, firewallResult, trends } = session; + + return ( +
+
+
Connect
+

Outside the box.

+

Share, export, integrate, and the long-running system surfaces.

+
+ {SUB_TABS.map((s) => ( + + ))} +
+
+ + Loading…
}> + {tab === 'share' && ( + <> + + + + + + + + + )} + + {tab === 'external' && ( + <> + + + + + + + + + + + )} + + {tab === 'system' && ( + <> + + + + + + + + + + + + + )} + + {tab === 'discover' && ( + <> + + + + )} + +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/workspaces/DefendWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/DefendWorkspace.jsx new file mode 100644 index 0000000..41f8bcd --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/DefendWorkspace.jsx @@ -0,0 +1,67 @@ +import React, { Suspense, lazy } from 'react'; +import ErrorBoundary from '../../components/ErrorBoundary'; +import CognitiveFirewallPanel from '../../components/CognitiveFirewallPanel'; + +const GeminiAnalysisPanel = lazy(() => import('../../components/GeminiAnalysisPanel')); +const GemmaAnalysisPanel = lazy(() => import('../../components/GemmaAnalysisPanel')); +const ImmunityPanel = lazy(() => import('../../components/ImmunityPanel')); +const RedTeamPanel = lazy(() => import('../../components/RedTeamPanel')); +const AdversarialTrainingPanel = lazy(() => import('../../components/AdversarialTrainingPanel')); +const BrainEvolvePanel = lazy(() => import('../../components/BrainEvolvePanel')); +const AttackEvolvePanel = lazy(() => import('../../components/AttackEvolvePanel')); +const CustomRulesPanel = lazy(() => import('../../components/CustomRulesPanel')); +const RulePacksPanel = lazy(() => import('../../components/RulePacksPanel')); +const EmbeddingsPanel = lazy(() => import('../../components/EmbeddingsPanel')); +const BypassSubmitPanel = lazy(() => import('../../components/BypassSubmitPanel')); +const AutopsyPanel = lazy(() => import('../../components/AutopsyPanel')); +const DiagnosticPanel = lazy(() => import('../../components/DiagnosticPanel')); +const CoveragePanel = lazy(() => import('../../components/CoveragePanel')); +const HypothesisPanel = lazy(() => import('../../components/HypothesisPanel')); +const FeedbackPanel = lazy(() => import('../../components/FeedbackPanel')); + +export default function DefendWorkspace({ session }) { + const { initialFirewallScan, onApplyFirewall, onApplyGemma, onApplyGemini, incomingImmunity, incomingAutopsy, onPromoteEvolve, onPromoteAttack } = session; + + return ( +
+
+
Defend
+

Catch the payload.

+

Score manipulation pressure, evolve the firewall, and rehearse against the red team.

+
+ + + + Loading…
}> + + + + +
+ Rule editor + + + + + +
+ +
+ Red team & evolve + + + + + +
+ +
+ Investigations + + + +
+ +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/workspaces/HomeWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/HomeWorkspace.jsx new file mode 100644 index 0000000..8a340a4 --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/HomeWorkspace.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import ControlsBar from '../../components/ControlsBar'; +import DemoTiles from '../../components/DemoTiles'; + +/** + * Home — landing workspace. Run controls + demo tiles for first-time + * users. The brain itself lives in BrainViewport which is rendered + * by AppShell and visible from every workspace. + */ +export default function HomeWorkspace({ session }) { + const { state, mode, quality, isRecording, exportStatus, gifOptions, onControls, onDemo } = session; + + return ( +
+
+
Welcome back
+

The brain is listening.

+

+ Drop a tweet, an email, a transcript, or a screenshot. Watch the brain react, + firewall score the manipulation pressure, and 100+ cognitive layers light up. + Pick a workspace on the left to go deeper. +

+
+ + + + +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/workspaces/KnowledgeWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/KnowledgeWorkspace.jsx new file mode 100644 index 0000000..27baa1b --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/KnowledgeWorkspace.jsx @@ -0,0 +1,52 @@ +import React, { Suspense, lazy } from 'react'; +import ErrorBoundary from '../../components/ErrorBoundary'; + +const KnowledgeBrainPanel = lazy(() => import('../../components/KnowledgeBrainPanel')); +const NeuroRagPanel = lazy(() => import('../../components/NeuroRagPanel')); +const ContextMemoryPanel = lazy(() => import('../../components/ContextMemoryPanel')); +const CodeBrainPanel = lazy(() => import('../../components/CodeBrainPanel')); +const MultimodalRagPanel = lazy(() => import('../../components/MultimodalRagPanel')); +const VectorGraphFusionPanel = lazy(() => import('../../components/VectorGraphFusionPanel')); +const SimilaritySearchPanel = lazy(() => import('../../components/SimilaritySearchPanel')); +const DirectInsertPanel = lazy(() => import('../../components/DirectInsertPanel')); +const EchoPanel = lazy(() => import('../../components/EchoPanel')); +const FingerprintPanel = lazy(() => import('../../components/FingerprintPanel')); +const ScanArchivePanel = lazy(() => import('../../components/ScanArchivePanel')); +const PersonalDictionaryPanel = lazy(() => import('../../components/PersonalDictionaryPanel')); + +export default function KnowledgeWorkspace({ session }) { + const { onApplyKnowledge, onApplyRag, onApplyMultimodalRag, onApplyFusedRag, onApplyCodeBrain } = session; + + return ( +
+
+
Knowledge
+

Feed the cortex.

+

Docs, code, retrieval, and the long memory of what you've already scanned.

+
+ + Loading…
}> + + + + +
+ Code & multimodal + + + + +
+ +
+ Memory & patterns + + + + + +
+ +
+ ); +} diff --git a/brainsnn-r3f-app/src/shell/workspaces/TrainingWorkspace.jsx b/brainsnn-r3f-app/src/shell/workspaces/TrainingWorkspace.jsx new file mode 100644 index 0000000..47a9adc --- /dev/null +++ b/brainsnn-r3f-app/src/shell/workspaces/TrainingWorkspace.jsx @@ -0,0 +1,55 @@ +import React, { Suspense, lazy } from 'react'; +import ErrorBoundary from '../../components/ErrorBoundary'; + +const DailyChallengePanel = lazy(() => import('../../components/DailyChallengePanel')); +const QuizPanel = lazy(() => import('../../components/QuizPanel')); +const BadgesPanel = lazy(() => import('../../components/BadgesPanel')); +const TextAdventurePanel = lazy(() => import('../../components/TextAdventurePanel')); +const DebatePanel = lazy(() => import('../../components/DebatePanel')); +const MacrosPanel = lazy(() => import('../../components/MacrosPanel')); +const ReplayPanel = lazy(() => import('../../components/ReplayPanel')); +const WeeklyRecapPanel = lazy(() => import('../../components/WeeklyRecapPanel')); +const ComplimentPanel = lazy(() => import('../../components/ComplimentPanel')); +const GenrePanel = lazy(() => import('../../components/GenrePanel')); +const PersonaPanel = lazy(() => import('../../components/PersonaPanel')); +const ComposerPanel = lazy(() => import('../../components/ComposerPanel')); + +export default function TrainingWorkspace({ session }) { + const { incomingDaily } = session; + return ( +
+
+
Training
+

Sharpen the eye.

+

Daily challenges, badges, adversarial scenarios, and reply craft.

+
+ + Loading…
}> + + + + +
+ Scenarios + + + + +
+ +
+ Reply craft + + + + +
+ +
+ Recap + +
+ +
+ ); +} diff --git a/brainsnn-r3f-app/src/styles/global.css b/brainsnn-r3f-app/src/styles/global.css index 27d340c..d676c2f 100644 --- a/brainsnn-r3f-app/src/styles/global.css +++ b/brainsnn-r3f-app/src/styles/global.css @@ -1,21 +1,5 @@ -:root{ - --bg:#0a0908; - --surface:#131210; - --surface-2:#181614; - --surface-3:#1d1b18; - --border:rgba(255,255,255,.08); - --text:#e8e6e3; - --muted:#908d89; - --faint:#4b4946; - --primary:#4fa8b3; - --gold:#e8b934; - --ok:#6daa45; - --danger:#dd6974; - --shadow:0 16px 44px rgba(0,0,0,.35); - --radius:20px; - --font-display:'Cabinet Grotesk','Satoshi',sans-serif; - --font-body:'Satoshi',sans-serif; -} +/* :root tokens moved to ./tokens.css (imported first from main.jsx). + * Keep this file focused on selectors; never redeclare vars here. */ *{box-sizing:border-box} html,body,#root{margin:0;min-height:100%} @@ -33,8 +17,7 @@ button{cursor:pointer} position:fixed; inset:0; background: - radial-gradient(circle at top right, rgba(79,168,179,.13), transparent 30%), - radial-gradient(circle at bottom left, rgba(232,185,52,.08), transparent 26%), + radial-gradient(circle at top right, var(--accent-soft), transparent 32%), var(--bg); pointer-events:none } @@ -69,7 +52,7 @@ button{cursor:pointer} font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; - color:var(--primary); + color:var(--accent); font-weight:800; margin-bottom:8px } @@ -104,12 +87,12 @@ h1,h2,h3{ .btn:hover, .chip-btn:hover{ - border-color:rgba(79,168,179,.35) + border-color:color-mix(in srgb, var(--accent) 35%, transparent) } .btn.primary, .chip-btn.active{ - background:var(--primary); + background:var(--accent); color:#091214; border-color:transparent; font-weight:800 @@ -274,7 +257,7 @@ h1,h2,h3{ .inline-bar span{ display:block; height:100%; - background:linear-gradient(90deg,var(--primary),var(--gold)); + background:linear-gradient(90deg,var(--accent),var(--warn)); border-radius:999px } @@ -302,12 +285,12 @@ h1,h2,h3{ .table-row:hover, .region-row:hover{ - border-color:rgba(79,168,179,.3) + border-color:color-mix(in srgb, var(--accent) 30%, transparent) } .region-row.active{ - border-color:rgba(79,168,179,.45); - background:rgba(79,168,179,.08) + border-color:color-mix(in srgb, var(--accent) 45%, transparent); + background:color-mix(in srgb, var(--accent) 8%, transparent) } .region-row-right{ @@ -339,7 +322,7 @@ h1,h2,h3{ .bar{ flex:1; border-radius:999px; - background:linear-gradient(180deg,#4fa8b3,#74d8e2); + background:linear-gradient(180deg,var(--accent),var(--accent-bright)); min-height:10px } @@ -371,12 +354,12 @@ h1,h2,h3{ height:12px; border-radius:999px; background:var(--danger); - box-shadow:0 0 0 4px rgba(221,105,116,.12) + box-shadow:0 0 0 4px color-mix(in srgb, var(--danger) 12%, transparent) } .status-dot.online{ background:var(--ok); - box-shadow:0 0 0 4px rgba(109,170,69,.12) + box-shadow:0 0 0 4px color-mix(in srgb, var(--ok) 12%, transparent) } .eeg-notes{ @@ -389,7 +372,7 @@ h1,h2,h3{ .timeline-panel input[type="range"]{ width:100%; - accent-color:var(--primary) + accent-color:var(--accent) } .timeline-stats{ @@ -461,7 +444,7 @@ h1,h2,h3{ .progress-bar span{ display:block; height:100%; - background:linear-gradient(90deg,var(--primary),var(--gold)); + background:linear-gradient(90deg,var(--accent),var(--warn)); width:0% } @@ -647,7 +630,7 @@ h1,h2,h3{ /* ---------- Snapshot Panel ---------- */ .snapshot-panel{ - border:1px solid rgba(232,185,52,.1); + border:1px solid color-mix(in srgb, var(--warn) 10%, transparent); background:linear-gradient(135deg,rgba(30,25,12,.4),var(--surface)) } @@ -743,8 +726,8 @@ h1,h2,h3{ } .btn-sm:hover{background:rgba(255,255,255,.08)} -.btn-sm.danger{color:var(--danger);border-color:rgba(221,105,116,.2)} -.btn-sm.danger:hover{background:rgba(221,105,116,.1)} +.btn-sm.danger{color:var(--danger);border-color:color-mix(in srgb, var(--danger) 20%, transparent)} +.btn-sm.danger:hover{background:color-mix(in srgb, var(--danger) 10%, transparent)} .snap-compare-trigger{ margin-top:14px; @@ -757,8 +740,8 @@ h1,h2,h3{ margin-top:16px; padding:14px; border-radius:14px; - background:rgba(232,185,52,.04); - border:1px solid rgba(232,185,52,.12) + background:color-mix(in srgb, var(--warn) 4%, transparent); + border:1px solid color-mix(in srgb, var(--warn) 12%, transparent) } .snap-compare-head{ @@ -809,8 +792,8 @@ h1,h2,h3{ min-width:2px } -.snap-bar-a{background:rgba(79,168,179,.5)} -.snap-bar-b{top:7px;border-radius:0 0 7px 7px;background:rgba(232,185,52,.5)} +.snap-bar-a{background:color-mix(in srgb, var(--accent) 50%, transparent)} +.snap-bar-b{top:7px;border-radius:0 0 7px 7px;background:color-mix(in srgb, var(--warn) 50%, transparent)} .snap-delta{ font-size:.8rem; @@ -827,7 +810,7 @@ h1,h2,h3{ /* ---------- Analytics Dashboard ---------- */ .analytics-dashboard{ - border:1px solid rgba(79,168,179,.12); + border:1px solid color-mix(in srgb, var(--accent) 12%, transparent); background:linear-gradient(135deg,rgba(10,22,28,.5),var(--surface)) } @@ -856,8 +839,8 @@ h1,h2,h3{ color:var(--muted) } -.analytics-pill.trend-rising{color:var(--ok);border-color:rgba(109,170,69,.2)} -.analytics-pill.trend-falling{color:var(--danger);border-color:rgba(221,105,116,.2)} +.analytics-pill.trend-rising{color:var(--ok);border-color:color-mix(in srgb, var(--ok) 20%, transparent)} +.analytics-pill.trend-falling{color:var(--danger);border-color:color-mix(in srgb, var(--danger) 20%, transparent)} .analytics-pill.alert-pill{color:#fdab43;border-color:rgba(253,171,67,.25);background:rgba(253,171,67,.08)} .analytics-trend-row{ @@ -926,9 +909,9 @@ h1,h2,h3{ } .analytics-alert.info{ - background:rgba(79,168,179,.06); - border:1px solid rgba(79,168,179,.12); - color:var(--primary) + background:color-mix(in srgb, var(--accent) 6%, transparent); + border:1px solid color-mix(in srgb, var(--accent) 12%, transparent); + color:var(--accent) } .analytics-alert-icon{font-size:1rem} @@ -1004,7 +987,7 @@ h1,h2,h3{ .narrative-status-line{ font-size:.85rem; - color:var(--primary); + color:var(--accent); margin:4px 0 0 } @@ -1081,10 +1064,10 @@ h1,h2,h3{ to{opacity:1;transform:translateX(0)} } -.toast-info{background:rgba(79,168,179,.15);border:1px solid rgba(79,168,179,.3);color:var(--primary)} -.toast-success{background:rgba(109,170,69,.15);border:1px solid rgba(109,170,69,.3);color:var(--ok)} +.toast-info{background:color-mix(in srgb, var(--accent) 15%, transparent);border:1px solid color-mix(in srgb, var(--accent) 30%, transparent);color:var(--accent)} +.toast-success{background:color-mix(in srgb, var(--ok) 15%, transparent);border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);color:var(--ok)} .toast-warning{background:rgba(253,171,67,.15);border:1px solid rgba(253,171,67,.3);color:#fdab43} -.toast-error{background:rgba(221,105,116,.15);border:1px solid rgba(221,105,116,.3);color:var(--danger)} +.toast-error{background:color-mix(in srgb, var(--danger) 15%, transparent);border:1px solid color-mix(in srgb, var(--danger) 30%, transparent);color:var(--danger)} .toast-icon{font-size:1rem;flex-shrink:0} .toast-message{flex:1} @@ -1150,7 +1133,7 @@ h1,h2,h3{ /* ---------- Share Panel ---------- */ .share-panel{ - border:1px solid rgba(109,170,69,.1); + border:1px solid color-mix(in srgb, var(--ok) 10%, transparent); background:linear-gradient(135deg,rgba(14,25,12,.4),var(--surface)) } @@ -1181,7 +1164,7 @@ h1,h2,h3{ .share-input.mono{font-family:monospace;font-size:.75rem} -.share-input:focus{outline:none;border-color:var(--primary)} +.share-input:focus{outline:none;border-color:var(--accent)} /* ---------- Onboarding Walkthrough ---------- */ @@ -1235,7 +1218,7 @@ h1,h2,h3{ transition:background .2s } -.onboard-dot.active{background:var(--primary);transform:scale(1.2)} +.onboard-dot.active{background:var(--accent);transform:scale(1.2)} .onboard-dot.done{background:var(--ok)} .onboard-actions{ @@ -1251,7 +1234,7 @@ h1,h2,h3{ } .onboard-highlight{ - outline:2px solid var(--primary) !important; + outline:2px solid var(--accent) !important; outline-offset:4px; border-radius:var(--radius); transition:outline .3s ease @@ -1262,7 +1245,7 @@ h1,h2,h3{ .split-brain-toggle .btn{margin-top:8px} .split-brain-panel{ - border:1px solid rgba(232,185,52,.1); + border:1px solid color-mix(in srgb, var(--warn) 10%, transparent); background:linear-gradient(135deg,rgba(28,24,12,.35),var(--surface)) } @@ -1303,7 +1286,7 @@ h1,h2,h3{ .split-canvas-wrap{width:100%;height:300px} -.split-divider{background:var(--gold);opacity:.4} +.split-divider{background:var(--warn);opacity:.4} .split-placeholder{ display:flex; @@ -1363,7 +1346,7 @@ h1,h2,h3{ .voice-slider{ width:100%; - accent-color:var(--primary) + accent-color:var(--accent) } .voice-status{ @@ -1387,15 +1370,15 @@ h1,h2,h3{ } .btn.danger{ - border-color:rgba(221,105,116,.3); + border-color:color-mix(in srgb, var(--danger) 30%, transparent); color:var(--danger) } -.btn.danger:hover{background:rgba(221,105,116,.1)} +.btn.danger:hover{background:color-mix(in srgb, var(--danger) 10%, transparent)} /* ---------- Plugin Panel ---------- */ .plugin-panel{ - border:1px solid rgba(109,170,69,.1); + border:1px solid color-mix(in srgb, var(--ok) 10%, transparent); background:linear-gradient(135deg,rgba(14,22,12,.35),var(--surface)) } @@ -1421,7 +1404,7 @@ h1,h2,h3{ font-size:.9rem } -.plugin-toggle input[type="checkbox"]{accent-color:var(--primary)} +.plugin-toggle input[type="checkbox"]{accent-color:var(--accent)} .plugin-version{ font-size:.72rem; @@ -1460,8 +1443,8 @@ h1,h2,h3{ border-radius:999px; font-size:.72rem; font-weight:600; - background:rgba(79,168,179,.12); - color:var(--primary) + background:color-mix(in srgb, var(--accent) 12%, transparent); + color:var(--accent) } .plugin-result-scores{ @@ -1482,14 +1465,14 @@ h1,h2,h3{ .plugin-combined{ padding:12px; border-radius:12px; - background:rgba(109,170,69,.06); - border:1px solid rgba(109,170,69,.12) + background:color-mix(in srgb, var(--ok) 6%, transparent); + border:1px solid color-mix(in srgb, var(--ok) 12%, transparent) } /* ---------- Live Sync Panel ---------- */ .live-sync-panel{ - border:1px solid rgba(79,168,179,.12); + border:1px solid color-mix(in srgb, var(--accent) 12%, transparent); background:linear-gradient(135deg,rgba(10,20,28,.4),var(--surface)) } @@ -1534,7 +1517,7 @@ h1,h2,h3{ } .live-chat-msg.local{color:var(--text)} -.live-chat-msg strong{color:var(--primary);margin-right:6px} +.live-chat-msg strong{color:var(--accent);margin-right:6px} .live-sync-chat-input{ display:flex; @@ -1582,9 +1565,9 @@ h1,h2,h3{ } .kb-input-tabs .btn-sm.active{ - background:var(--primary); + background:var(--accent); color:#fff; - border-color:var(--primary) + border-color:var(--accent) } .kb-results{margin-top:16px} @@ -1668,8 +1651,8 @@ h1,h2,h3{ .kb-gap-card{ padding:10px 12px; border-radius:10px; - background:rgba(221,105,116,.05); - border:1px solid rgba(221,105,116,.12) + background:color-mix(in srgb, var(--danger) 5%, transparent); + border:1px solid color-mix(in srgb, var(--danger) 12%, transparent) } .kb-gap-head{ @@ -1688,7 +1671,7 @@ h1,h2,h3{ .kb-gap-card p{margin:4px 0 0;font-size:.84rem} .kb-gap-suggestion{ - color:var(--primary) !important; + color:var(--accent) !important; font-style:italic } @@ -1720,7 +1703,7 @@ h1,h2,h3{ align-self:flex-start } -.kb-priority-high{background:rgba(221,105,116,.12);color:var(--danger)} +.kb-priority-high{background:color-mix(in srgb, var(--danger) 12%, transparent);color:var(--danger)} .kb-priority-medium{background:rgba(253,171,67,.1);color:#fdab43} .kb-suggestion-action{ @@ -1750,7 +1733,7 @@ h1,h2,h3{ background:var(--surface-3); font-family:monospace; font-size:.78rem; - color:var(--primary); + color:var(--accent); word-break:break-all } @@ -1790,8 +1773,8 @@ h1,h2,h3{ /* ---------- Error Boundary ---------- */ .error-boundary-panel{ - border:1px solid rgba(221,105,116,.2); - background:rgba(221,105,116,.04) + border:1px solid color-mix(in srgb, var(--danger) 20%, transparent); + background:color-mix(in srgb, var(--danger) 4%, transparent) } .error-boundary-content{ @@ -1805,7 +1788,7 @@ h1,h2,h3{ width:32px; height:32px; border-radius:50%; - background:rgba(221,105,116,.15); + background:color-mix(in srgb, var(--danger) 15%, transparent); color:var(--danger); display:flex; align-items:center; @@ -1828,12 +1811,12 @@ h1,h2,h3{ /* ---------- A11y / focus styles ---------- */ .btn:focus-visible,.btn-sm:focus-visible{ - outline:2px solid var(--primary); + outline:2px solid var(--accent); outline-offset:2px } .snap-name-input:focus-visible,.firewall-input:focus-visible,.share-input:focus-visible{ - outline:2px solid var(--primary); + outline:2px solid var(--accent); outline-offset:1px } @@ -1931,7 +1914,7 @@ h1,h2,h3{ border-radius:4px } .mcp-audit-row.ok{background:rgba(76,175,80,.08);color:#a3d9a5} -.mcp-audit-row.err{background:rgba(221,105,116,.1);color:#f0a8b0} +.mcp-audit-row.err{background:color-mix(in srgb, var(--danger) 10%, transparent);color:#f0a8b0} .mcp-audit-ms{opacity:.7} .mcp-audit-status{text-transform:uppercase;font-size:.7rem;opacity:.8} .mcp-config{display:flex;flex-direction:column;gap:8px} @@ -1960,7 +1943,7 @@ h1,h2,h3{ min-height:160px; resize:vertical } -.code-brain-input:focus-visible{outline:2px solid var(--primary);outline-offset:1px} +.code-brain-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .code-brain-actions{display:flex;gap:8px;flex-wrap:wrap} .code-brain-stats{ display:grid; @@ -1994,7 +1977,7 @@ h1,h2,h3{ border-radius:6px; font-size:.85rem } -.code-brain-query:focus-visible{outline:2px solid var(--primary);outline-offset:1px} +.code-brain-query:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .code-brain-results{ display:flex;flex-direction:column;gap:4px; max-height:260px;overflow:auto @@ -2058,7 +2041,7 @@ h1,h2,h3{ font-size:.82rem } .steward-toggle{justify-content:flex-start} -.steward-toggle input{accent-color:var(--primary)} +.steward-toggle input{accent-color:var(--accent)} .steward-log{ display:flex;flex-direction:column;gap:4px; padding:10px; @@ -2084,11 +2067,11 @@ h1,h2,h3{ text-align:center; background:rgba(255,255,255,.05) } -.steward-log-row.kind-anomaly .steward-log-badge{background:rgba(221,105,116,.2);color:#f0a8b0} +.steward-log-row.kind-anomaly .steward-log-badge{background:color-mix(in srgb, var(--danger) 20%, transparent);color:#f0a8b0} .steward-log-row.kind-snapshot .steward-log-badge{background:rgba(127,178,255,.2);color:#cfe1ff} -.steward-log-row.kind-narration .steward-log-badge{background:rgba(109,170,69,.2);color:#b4d89c} +.steward-log-row.kind-narration .steward-log-badge{background:color-mix(in srgb, var(--ok) 20%, transparent);color:#b4d89c} .steward-log-row.kind-scenario .steward-log-badge{background:rgba(253,171,67,.2);color:#f5c888} -.steward-log-row.kind-error .steward-log-badge{background:rgba(221,105,116,.3);color:#ffb4b4} +.steward-log-row.kind-error .steward-log-badge{background:color-mix(in srgb, var(--danger) 30%, transparent);color:#ffb4b4} /* ---------- Layer 22: Conversation Brain ---------- */ @@ -2104,7 +2087,7 @@ h1,h2,h3{ min-height:160px; resize:vertical } -.convo-input:focus-visible{outline:2px solid var(--primary);outline-offset:1px} +.convo-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .convo-actions{display:flex;gap:8px;flex-wrap:wrap} .convo-summary-row{ display:grid; @@ -2134,7 +2117,7 @@ h1,h2,h3{ padding:0 } .convo-bar:hover{filter:brightness(1.2)} -.convo-bar.peak{background:linear-gradient(180deg, #ff8090, #dd6974)} +.convo-bar.peak{background:var(--danger)} .convo-bar.selected{transform:scaleY(1.08) translateY(-3px);outline:1px solid rgba(255,255,255,.35)} .convo-turn-detail{ padding:12px; @@ -2148,7 +2131,7 @@ h1,h2,h3{ margin:0; padding:10px 14px; background:rgba(0,0,0,.25); - border-left:3px solid var(--primary); + border-left:3px solid var(--accent); border-radius:4px; font-size:.86rem; line-height:1.5; @@ -2317,8 +2300,8 @@ h1,h2,h3{ .embeddings-state-error{color:#ff8090} .embeddings-error{ padding:8px; - background:rgba(221,105,116,.12); - border:1px solid rgba(221,105,116,.3); + background:color-mix(in srgb, var(--danger) 12%, transparent); + border:1px solid color-mix(in srgb, var(--danger) 30%, transparent); color:#f0a8b0; border-radius:4px; font-size:.8rem; @@ -2342,7 +2325,7 @@ h1,h2,h3{ border-radius:6px; font-size:.85rem } -.embeddings-test-input:focus-visible{outline:2px solid var(--primary);outline-offset:1px} +.embeddings-test-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .embeddings-test-result{ padding:8px; background:rgba(0,0,0,.25); @@ -2583,7 +2566,7 @@ h1,h2,h3{ border:1px solid rgba(255,255,255,.1); border-radius:6px } -.rag-docs-input:focus-visible{outline:2px solid var(--primary);outline-offset:1px} +.rag-docs-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .rag-actions{display:flex;gap:10px;align-items:center} .rag-query-row{display:flex;gap:8px} .rag-query-input{ @@ -2594,7 +2577,7 @@ h1,h2,h3{ border-radius:6px; font-size:13px } -.rag-query-input:focus-visible{outline:2px solid var(--primary);outline-offset:1px} +.rag-query-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .rag-error{ padding:10px; background:rgba(255,128,144,.12); @@ -2650,7 +2633,7 @@ h1,h2,h3{ background:#0b0b0d;color:#eee;font:500 13px/1.5 'Inter',sans-serif; resize:vertical; } -.affect-input:focus-visible{outline:2px solid var(--primary);outline-offset:1px} +.affect-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .affect-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center} .affect-clusters{ display:grid;grid-template-columns:repeat(4,1fr);gap:8px; @@ -2749,8 +2732,8 @@ h1,h2,h3{ } .nt-preset:hover{border-color:rgba(255,255,255,.25);transform:translateY(-1px)} .nt-preset.active{ - border-color:var(--primary); - background:rgba(79,168,179,.12); + border-color:var(--accent); + background:color-mix(in srgb, var(--accent) 12%, transparent); color:#fff; } .nt-preset-icon{font-size:14px} @@ -2788,8 +2771,8 @@ h1,h2,h3{ .nt-status{ display:flex;align-items:center;gap:8px;flex-wrap:wrap; padding:8px 12px;border-radius:8px; - background:rgba(79,168,179,.07); - border-left:3px solid var(--primary); + background:color-mix(in srgb, var(--accent) 7%, transparent); + border-left:3px solid var(--accent); } .nt-status-text{ font-family:'JetBrains Mono',monospace; @@ -2826,7 +2809,7 @@ h1,h2,h3{ padding:8px 0; } .nt-gain-row label{display:flex;align-items:center;gap:10px;flex:1;min-width:240px} -.nt-gain-row input[type="range"]{flex:1;accent-color:var(--primary)} +.nt-gain-row input[type="range"]{flex:1;accent-color:var(--accent)} .nt-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center} /* ---------- Layer 31 — Brain Evolve ---------- */ @@ -2853,7 +2836,7 @@ h1,h2,h3{ .evolve-field input:disabled, .evolve-field select:disabled{opacity:0.55;cursor:not-allowed} .evolve-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center} -.evolve-actions .primary{background:var(--primary);color:#0c0e13;border:none;padding:8px 14px;border-radius:8px;font-weight:600;cursor:pointer} +.evolve-actions .primary{background:var(--accent);color:#0c0e13;border:none;padding:8px 14px;border-radius:8px;font-weight:600;cursor:pointer} .evolve-actions .danger{background:#d95c6d;color:#120809;border:none;padding:8px 14px;border-radius:8px;font-weight:600;cursor:pointer} .evolve-actions button{background:var(--surface-2,#181b22);color:var(--text,#e8eaed);border:1px solid var(--border,#2a2e38);padding:8px 12px;border-radius:8px;cursor:pointer} .evolve-actions button:disabled{opacity:0.5;cursor:not-allowed} @@ -2898,7 +2881,7 @@ h1,h2,h3{ font-size:12px; transition:border-color 0.15s ease,transform 0.1s ease; } -.evolve-leader-row:hover{border-color:var(--primary)} +.evolve-leader-row:hover{border-color:var(--accent)} .evolve-leader-row.active{border-color:#7dd87f;transform:translateX(2px)} .evolve-rank{font-family:'JetBrains Mono',monospace;color:var(--muted);font-size:10px} .evolve-leader-name{font-family:'JetBrains Mono',monospace;color:var(--text,#e8eaed)} diff --git a/brainsnn-r3f-app/src/styles/shell.css b/brainsnn-r3f-app/src/styles/shell.css new file mode 100644 index 0000000..40db765 --- /dev/null +++ b/brainsnn-r3f-app/src/styles/shell.css @@ -0,0 +1,443 @@ +/** + * Shell-only styles — the Claude-design workspace layout. + * + * Loaded only when ?shell=new is active. Coexists with the legacy + * global.css; the legacy app uses .app-layout, the new shell uses + * .shell-root / .shell-main / .shell-column. + */ + +.shell-host { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.shell-root { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1640px; + margin: 0 auto; + width: 100%; +} + +/* ---------- topbar ---------- */ +.shell-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + border-bottom: var(--hairline); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 10; +} +.shell-topbar-left { display: flex; align-items: center; gap: 12px; } +.shell-topbar-right { display: flex; align-items: center; gap: 8px; } + +.shell-wordmark { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.2rem; + letter-spacing: -0.01em; + color: var(--text); +} +.shell-breadcrumb-sep { color: var(--faint); } +.shell-breadcrumb { + color: var(--muted); + font-size: 0.92rem; + font-family: var(--font-display); + font-style: italic; +} + +.shell-icon-btn { + appearance: none; + background: transparent; + border: var(--hairline); + color: var(--muted); + border-radius: var(--radius-sm); + width: 36px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.92rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.shell-icon-btn:hover { border-color: var(--accent-line); color: var(--text); background: var(--accent-soft); } +.shell-kbd { + font-family: var(--font-mono); + font-size: 0.78rem; + letter-spacing: 0.02em; +} + +.shell-stat-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: var(--hairline); + border-radius: 999px; + font-size: 0.78rem; + color: var(--muted); + background: var(--surface); +} +.shell-stat-dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +/* ---------- main grid ---------- */ +.shell-main { + display: grid; + grid-template-columns: 200px minmax(0, 1fr) 340px; + gap: 24px; + padding: 20px 24px 40px; + flex: 1; + min-height: 0; +} + +/* ---------- tabs (left rail) ---------- */ +.shell-tabs { + display: flex; + flex-direction: column; + gap: 4px; + position: sticky; + top: 78px; + align-self: start; + padding-top: 4px; +} +.shell-tab { + appearance: none; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 10px 12px; + color: var(--muted); + display: grid; + grid-template-columns: 18px 1fr auto; + align-items: center; + gap: 10px; + font-size: 0.94rem; + text-align: left; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.shell-tab:hover { color: var(--text); border-color: var(--border); background: var(--surface); } +.shell-tab.is-active { + color: var(--text); + background: var(--accent-soft); + border-color: var(--accent-line); +} +.shell-tab-glyph { color: var(--accent); font-size: 1rem; } +.shell-tab-chord { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--faint); + background: color-mix(in srgb, var(--bg) 50%, transparent); + padding: 2px 6px; + border-radius: 4px; +} + +/* ---------- workspace column ---------- */ +.shell-column { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +/* ---------- viewport ---------- */ +.shell-viewport.is-strip { + position: sticky; + top: 78px; + z-index: 5; + max-height: 240px; + overflow: hidden; +} +.shell-viewport.is-strip .viewer-canvas-wrap { height: 200px; } +.shell-viewport.is-promoted .viewer-canvas-wrap { height: 68vh; } + +/* ---------- composer ---------- */ +.shell-composer { + display: flex; + gap: 8px; + align-items: center; + padding: 10px 14px; + background: var(--surface); + border: var(--hairline); + border-radius: var(--radius); + position: sticky; + top: calc(78px + 260px); + z-index: 4; +} +.shell-composer-mode { + background: var(--surface-2); + color: var(--text); + border: var(--hairline); + border-radius: var(--radius-sm); + padding: 8px 10px; + font-size: 0.85rem; + font-family: var(--font-body); +} +.shell-composer-input { + flex: 1; + background: transparent; + border: none; + color: var(--text); + font-size: 0.98rem; + font-family: var(--font-body); + padding: 8px 4px; + outline: none; +} +.shell-composer-input::placeholder { color: var(--faint); } +.shell-composer-send { + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius-sm); + padding: 8px 16px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s, background 0.15s; +} +.shell-composer-send:disabled { opacity: 0.4; cursor: not-allowed; } +.shell-composer-send:hover:not(:disabled) { background: var(--accent-bright); } + +/* ---------- workspace body ---------- */ +.shell-workspace-host { min-height: 0; } +.shell-workspace { + display: flex; + flex-direction: column; + gap: 16px; +} +.shell-workspace-header { + padding: 8px 4px 16px; + border-bottom: var(--hairline); + margin-bottom: 8px; +} +.shell-workspace-header h1 { + font-family: var(--font-display); + font-size: 1.85rem; + font-weight: 600; + margin: 4px 0 6px; + letter-spacing: -0.01em; +} +.shell-workspace-header .muted { + font-size: 0.95rem; + max-width: 60ch; +} + +/* sub-tabs (Connect workspace) */ +.shell-subtabs { + display: inline-flex; + gap: 4px; + margin-top: 12px; + padding: 4px; + background: var(--surface); + border: var(--hairline); + border-radius: var(--radius-sm); +} +.shell-subtab { + background: transparent; + color: var(--muted); + border: none; + border-radius: var(--radius-sm); + padding: 6px 14px; + font-size: 0.88rem; + cursor: pointer; + font-family: var(--font-body); +} +.shell-subtab:hover { color: var(--text); } +.shell-subtab.is-active { + background: var(--accent-soft); + color: var(--text); +} + +/* drawer (collapsible workspace section) */ +.shell-drawer { + background: var(--surface); + border: var(--hairline); + border-radius: var(--radius); + padding: 4px 14px; +} +.shell-drawer summary { + cursor: pointer; + padding: 12px 6px; + font-family: var(--font-display); + font-size: 1rem; + color: var(--muted); + list-style: none; + display: flex; + align-items: center; + gap: 8px; +} +.shell-drawer summary::before { + content: '▸'; + color: var(--faint); + transition: transform 0.15s; +} +.shell-drawer[open] summary::before { transform: rotate(90deg); } +.shell-drawer[open] { + padding-bottom: 14px; +} +.shell-drawer > *:not(summary) { margin-top: 12px; } + +/* ---------- sidebar ---------- */ +.shell-sidebar { + display: flex; + flex-direction: column; + gap: 16px; + position: sticky; + top: 78px; + align-self: start; + max-height: calc(100vh - 100px); + overflow-y: auto; + padding-right: 4px; +} +.shell-side-section { + background: var(--surface); + border: var(--hairline); + border-radius: var(--radius); + padding: 4px 14px; +} +.shell-side-section summary { + cursor: pointer; + padding: 12px 6px; + font-family: var(--font-display); + font-size: 0.95rem; + color: var(--muted); +} + +/* ---------- flash highlight ---------- */ +.shell-flash { + box-shadow: 0 0 0 3px var(--accent-line), 0 0 24px var(--accent-soft); + transition: box-shadow 0.3s; +} + +/* ---------- update banner ---------- */ +.shell-update-banner { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--surface); + border: var(--hairline); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + z-index: 50; + font-size: 0.88rem; + color: var(--text); + animation: shell-update-banner-in 0.3s ease-out; +} +@keyframes shell-update-banner-in { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +.shell-update-banner-msg { color: var(--muted); } +.shell-update-banner-action { + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius-sm); + padding: 6px 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.shell-update-banner-action:hover { background: var(--accent-bright); } +.shell-update-banner-dismiss { + background: transparent; + color: var(--faint); + border: none; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; +} +.shell-update-banner-dismiss:hover { color: var(--text); } + +/* ---------- accessibility ---------- */ + +/* Skip-to-content — visible only on focus. */ +.shell-skip-link { + position: absolute; + top: -100px; + left: 16px; + background: var(--accent); + color: white; + padding: 10px 16px; + border-radius: var(--radius-sm); + text-decoration: none; + font-weight: 600; + z-index: 100; + transition: top 0.15s; +} +.shell-skip-link:focus { + top: 12px; + outline: 2px solid var(--accent-bright); + outline-offset: 2px; +} + +/* Consistent focus ring across shell controls. Browsers handle + :focus-visible so mouse clicks don't trigger the ring; only + keyboard navigation does. */ +.shell-tab:focus-visible, +.shell-icon-btn:focus-visible, +.shell-composer-input:focus-visible, +.shell-composer-mode:focus-visible, +.shell-composer-send:focus-visible, +.shell-subtab:focus-visible, +.shell-workspace-header h1:focus-visible { + outline: 2px solid var(--accent-line); + outline-offset: 2px; + border-radius: var(--radius-sm); +} +/* The heading isn't a control, so its focus state shouldn't look + identical to a button. Soften it. */ +.shell-workspace-header h1:focus { + outline: none; +} +.shell-workspace-header h1:focus-visible { + outline: 2px dashed var(--accent-line); + outline-offset: 4px; +} + +/* ---------- responsive ---------- */ +@media (max-width: 1280px) { + .shell-main { grid-template-columns: 180px minmax(0, 1fr) 300px; gap: 16px; padding: 16px; } +} +@media (max-width: 1050px) { + .shell-main { grid-template-columns: 64px minmax(0, 1fr); } + .shell-sidebar { display: none; } + .shell-tab-label, .shell-tab-chord { display: none; } +} +@media (max-width: 760px) { + .shell-main { + grid-template-columns: 1fr; + padding: 12px; + } + .shell-tabs { + flex-direction: row; + overflow-x: auto; + position: static; + padding: 4px; + background: var(--surface); + border: var(--hairline); + border-radius: var(--radius); + } + .shell-tab { grid-template-columns: 18px 1fr; padding: 8px 10px; } + .shell-tab-label { display: inline; } +} diff --git a/brainsnn-r3f-app/src/styles/tokens.css b/brainsnn-r3f-app/src/styles/tokens.css new file mode 100644 index 0000000..6abc987 --- /dev/null +++ b/brainsnn-r3f-app/src/styles/tokens.css @@ -0,0 +1,99 @@ +/** + * Design tokens — Claude dark, quieter. + * + * Imported FIRST from main.jsx (before global.css) so every selector + * resolves against these vars. Light-mode + high-contrast overrides + * live in this file too so theme.js continues to work end-to-end. + */ + +:root { + /* surfaces — warm dark gray, slightly lifted from inky black */ + --bg: #1a1815; + --surface: #211e1a; + --surface-2: #2a2620; + --surface-3: #353029; + --bg-3d: #0f0e0c; + --border: rgba(255, 255, 255, 0.06); + + /* text */ + --text: #f0ede6; + --muted: #a39d92; + --faint: #5c5853; + + /* single accent — Claude rust */ + --accent: #c96442; + --accent-soft: rgba(201, 100, 66, 0.14); + --accent-line: rgba(201, 100, 66, 0.32); + --accent-bright: #e08868; + + /* semantic feedback — never used for branding */ + --ok: #7a9c5f; + --warn: #c9a14b; + --danger: #c46666; + + /* secondary severity palette — fills the brighter end of the + ok/warn/danger ladder. Used by 3-tier severity tables in + utils/* (heatmap, diagnostic, hypothesis, etc.) where the + UI shows danger/middle/ok bars or borders. */ + --severity-mid: #fdab43; /* bright orange — "Tilted" tier */ + --severity-ok: #5ee69a; /* bright mint — "Resilient" tier */ + --severity-info: #77dbe4; /* cyan — "Steady" / neutral tier */ + --severity-purple: #a86fdf; /* certainty / authority */ + --severity-pink: #ec87b5; /* belonging / social */ + + /* typography — Inter for UI, Source Serif 4 for headlines */ + --font-display: 'Source Serif 4', ui-serif, Georgia, serif; + --font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + + /* layout — quieter than before */ + --radius: 12px; + --radius-sm: 8px; + + /* no drop shadows — hairlines instead. Existing references to + * var(--shadow) at global.css:63, 1076, 1115, 1207 cascade to "none". */ + --shadow: none; + --hairline: 1px solid var(--border); +} + +/* Light mode — framework already wired via utils/theme.js (data-theme="light"), + * just dormant until tokens are defined. */ +html[data-theme="light"] { + --bg: #faf9f7; + --surface: #ffffff; + --surface-2: #f4f1ec; + --surface-3: #e8e3da; + --bg-3d: #f2efe9; + --border: rgba(0, 0, 0, 0.08); + + --text: #1a1815; + --muted: #5c5853; + --faint: #a39d92; + + --accent-soft: rgba(201, 100, 66, 0.10); + --accent-line: rgba(201, 100, 66, 0.28); + + /* Severity palette tuned slightly darker for light backgrounds. */ + --severity-mid: #d97a1f; + --severity-ok: #2f9a52; + --severity-info: #2b8a9e; + --severity-purple: #7c4ec0; + --severity-pink: #c25d92; +} + +/* High contrast — boost edges, fully opaque borders */ +html[data-high-contrast="true"] { + --border: rgba(255, 255, 255, 0.22); + --muted: #cfcabf; + --hairline: 1px solid var(--border); +} + +html[data-theme="light"][data-high-contrast="true"] { + --border: rgba(0, 0, 0, 0.32); + --muted: #2a2620; +} + +/* Reduced motion — theme.js sets --bsnn-anim-duration: 0ms; reinforce here. */ +@media (prefers-reduced-motion: reduce) { + :root { --bsnn-anim-duration: 0ms; } +} diff --git a/brainsnn-r3f-app/src/utils/adversarialTraining.js b/brainsnn-r3f-app/src/utils/adversarialTraining.js index 83c793c..dae5226 100644 --- a/brainsnn-r3f-app/src/utils/adversarialTraining.js +++ b/brainsnn-r3f-app/src/utils/adversarialTraining.js @@ -83,6 +83,18 @@ function extractCandidates(text) { * that the current firewall failed to catch at the given threshold. * Returns a new learned-pattern list (not yet persisted — caller decides). */ +/** + * Async sibling. Offloads the n-gram lift mining to the firewall + * worker; falls back inline. Pure-data input + output, no rule + * state needed. + */ +export async function trainFromRedTeamAsync(report, opts = {}) { + const { callFirewallWorker } = await import('./cognitiveFirewall.js'); + const result = await callFirewallWorker('trainFromRedTeam', { report, opts }); + if (result) return result; + return trainFromRedTeam(report, opts); +} + export function trainFromRedTeam(report, { threshold = 0.3, topK = 20, minLift = 3, minAttackCount = 2 } = {}) { if (!report?.perAttack) return { learned: [], stats: null }; diff --git a/brainsnn-r3f-app/src/utils/atomicWrites.js b/brainsnn-r3f-app/src/utils/atomicWrites.js new file mode 100644 index 0000000..7ff37e5 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/atomicWrites.js @@ -0,0 +1,48 @@ +/** + * atomicWrites — Web Locks API wrapper for cross-tab atomicity. + * + * Some writes (snapshot list, custom-rules list, archive append) can + * race when two tabs save simultaneously — last-write-wins clobbers + * the other tab's change. + * + * Web Locks API serializes a named critical section across all + * same-origin tabs. Falls back to a per-tab Promise queue when the + * API is unavailable (Firefox < 96 etc.). + */ + +const _localQueues = new Map(); + +function locksAvailable() { + return typeof navigator !== 'undefined' && navigator.locks && typeof navigator.locks.request === 'function'; +} + +/** + * Run `fn` inside an exclusive lock named `name`. The lock auto-releases + * when `fn` resolves or rejects. Cross-tab on supporting browsers, + * single-tab serial on others. + */ +export async function withLock(name, fn) { + if (locksAvailable()) { + return navigator.locks.request(name, { mode: 'exclusive' }, async () => fn()); + } + // Fallback: per-tab Promise chain so at least same-tab callers + // don't race each other. + const prev = _localQueues.get(name) || Promise.resolve(); + const next = prev.then(() => fn(), () => fn()); + _localQueues.set(name, next.catch(() => { /* swallow for next chain link */ })); + return next; +} + +/** + * Convenience: read → modify → write under a single lock. The reader + * receives the existing value (or `init` if absent); whatever it + * returns is written back. + */ +export async function mutate(name, store, key, init, fn) { + return withLock(`${name}:${key}`, async () => { + const current = (await store.get(key)) ?? init; + const next = await fn(current); + await store.set(key, next); + return next; + }); +} diff --git a/brainsnn-r3f-app/src/utils/autopsyCard.js b/brainsnn-r3f-app/src/utils/autopsyCard.js index 2e29151..4abb3da 100644 --- a/brainsnn-r3f-app/src/utils/autopsyCard.js +++ b/brainsnn-r3f-app/src/utils/autopsyCard.js @@ -8,10 +8,10 @@ */ export const AUTOPSY_LEVELS = [ - { min: 0.65, label: 'Hostile', color: '#dd6974' }, + { min: 0.65, label: 'Hostile', color: 'var(--danger)' }, { min: 0.45, label: 'Heavy', color: '#e57b40' }, - { min: 0.28, label: 'Tilted', color: '#fdab43' }, - { min: 0.0, label: 'Steady', color: '#6daa45' }, + { min: 0.28, label: 'Tilted', color: 'var(--severity-mid)' }, + { min: 0.0, label: 'Steady', color: 'var(--ok)' }, ]; export function autopsyLevelFor(pressure) { diff --git a/brainsnn-r3f-app/src/utils/capabilities.js b/brainsnn-r3f-app/src/utils/capabilities.js new file mode 100644 index 0000000..da26ea4 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/capabilities.js @@ -0,0 +1,88 @@ +/** + * capabilities — runtime feature detection for the Beast stack. + * + * Reports which performance + offline features are actually available + * in the current browser. Surfaces in the Privacy Budget panel and + * gates opt-in flags (?engine=webgpu, etc.) so we don't try to use + * something the browser can't deliver. + * + * All probes are cheap and synchronous — except WebGPU which requires + * an async adapter request. That one is cached after first await. + */ + +let _webgpuPromise = null; + +export function hasBroadcastChannel() { + return typeof BroadcastChannel !== 'undefined'; +} + +export function hasWebLocks() { + return typeof navigator !== 'undefined' + && !!navigator.locks + && typeof navigator.locks.request === 'function'; +} + +export function hasBackgroundSync() { + return typeof window !== 'undefined' + && 'SyncManager' in window; +} + +export function hasOffscreenCanvas() { + return typeof OffscreenCanvas !== 'undefined'; +} + +export function hasFileSystemAccess() { + return typeof window !== 'undefined' + && typeof window.showSaveFilePicker === 'function'; +} + +export function hasWorker() { + return typeof Worker !== 'undefined'; +} + +export function hasIndexedDB() { + try { return typeof indexedDB !== 'undefined' && indexedDB !== null; } + catch { return false; } +} + +export function cores() { + if (typeof navigator === 'undefined') return 1; + return navigator.hardwareConcurrency || 2; +} + +/** + * WebGPU is async because it requires requestAdapter(). Returns a + * stable cached Promise; safe to call repeatedly. + */ +export function hasWebGPU() { + if (_webgpuPromise) return _webgpuPromise; + if (typeof navigator === 'undefined' || !navigator.gpu) { + _webgpuPromise = Promise.resolve(false); + return _webgpuPromise; + } + _webgpuPromise = (async () => { + try { + const adapter = await navigator.gpu.requestAdapter(); + return !!adapter; + } catch { return false; } + })(); + return _webgpuPromise; +} + +/** + * Snapshot of every probe except hasWebGPU (which is async). Returns + * a flat object ready for JSON rendering in the Privacy Budget / + * Theme settings panel. + */ +export function capabilitySnapshot() { + return { + cores: cores(), + worker: hasWorker(), + indexedDB: hasIndexedDB(), + broadcastChannel: hasBroadcastChannel(), + webLocks: hasWebLocks(), + backgroundSync: hasBackgroundSync(), + offscreenCanvas: hasOffscreenCanvas(), + fileSystemAccess: hasFileSystemAccess() + }; +} diff --git a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js index 8271775..22c1c86 100644 --- a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js +++ b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js @@ -1,5 +1,6 @@ import { detectTemplates } from './propagandaTemplates.js'; import { detectLanguage, patternsFor, labelFor as languageLabel } from './firewallI18n.js'; +import { createPool } from './workerPool.js'; const URGENCY_PATTERNS = [ /\bnow\b|\bimmediately\b|\burgent\b|\bbreaking\b|\balert\b/gi, @@ -82,9 +83,9 @@ function normalize(count, baseline = 3) { } export const SCORE_FIELDS = [ - { key: 'emotionalActivation', label: 'Emotional activation', desc: 'Fear / outrage / panic optimization', color: '#dd6974', regions: 'AMY + THL' }, - { key: 'cognitiveSuppression', label: 'Cognitive suppression', desc: 'Urgency / certainty theater / overload', color: '#fdab43', regions: 'PFC dampens' }, - { key: 'manipulationPressure', label: 'Manipulation pressure', desc: 'Steering reaction over understanding', color: '#a86fdf', regions: 'BG rises' }, + { key: 'emotionalActivation', label: 'Emotional activation', desc: 'Fear / outrage / panic optimization', color: 'var(--danger)', regions: 'AMY + THL' }, + { key: 'cognitiveSuppression', label: 'Cognitive suppression', desc: 'Urgency / certainty theater / overload', color: 'var(--severity-mid)', regions: 'PFC dampens' }, + { key: 'manipulationPressure', label: 'Manipulation pressure', desc: 'Steering reaction over understanding', color: 'var(--severity-purple)', regions: 'BG rises' }, { key: 'trustErosion', label: 'Trust erosion risk', desc: 'Sensationalism / coercive framing', color: '#5591c7', regions: 'composite' } ]; @@ -226,6 +227,80 @@ export async function scoreContentSmart(text = '') { return { ...scoreContent(text), source: 'regex' }; } +/** + * Async scoring — offloads regex sweep to the firewall worker when text is + * long enough that the spin-up overhead pays back. Falls back to sync + * scoreContent on any failure (or when running inside the worker itself, + * to avoid recursive spawning). + * + * Threshold: 500 chars. Below that the sync path completes in <2ms; the + * postMessage round-trip costs more than the work itself. + */ +const ASYNC_THRESHOLD = 500; +let _firewallPool = null; + +function ensurePool() { + if (_firewallPool) return _firewallPool; + // Inside a worker, window is undefined — never instantiate another pool. + if (typeof window === 'undefined') return null; + try { + _firewallPool = createPool( + () => new Worker(new URL('../workers/firewall.worker.js', import.meta.url), { type: 'module' }), + { + // size omitted → defaults to min(4, cores-1). Red Team batch scans + // and parallel inbox triage benefit from real parallelism here. + fallback: (type, payload) => { + if (type === 'score') return scoreContent(payload?.text || ''); + if (type === 'scoreWithRules') return scoreContentWithRules(payload?.text || '', payload?.rules || DEFAULT_RULES); + return null; + } + } + ); + return _firewallPool; + } catch { + return null; + } +} + +/** + * Generic worker dispatch for sister modules (redTeam, adversarial- + * Training) that want to share the firewall pool without re-spawning + * their own workers. Returns the worker result on success, or null + * on fallback / no-pool — caller decides whether to run inline. + */ +export async function callFirewallWorker(type, payload, transferList) { + const pool = ensurePool(); + if (!pool || pool.degraded) return null; + try { + return await pool.call(type, payload, transferList); + } catch { + return null; + } +} + +export async function scoreContentAsync(text = '') { + if (!text || text.length < ASYNC_THRESHOLD) return scoreContent(text); + const pool = ensurePool(); + if (!pool || pool.degraded) return scoreContent(text); + try { + // Workers carry their own module-level state, so the main-thread + // active ruleset (custom rules, evolved firewall, rule packs) does + // NOT propagate automatically. When the user has promoted a non- + // default ruleset, send it along with each score call so the worker + // mirrors the same behavior as scoreContent on the main thread. + const rules = getActiveRules(); + if (rules === DEFAULT_RULES) { + return await pool.call('score', { text }); + } + return await pool.call('scoreWithRules', { + text, + rules: serializeRules(rules) + }); + } catch { + return scoreContent(text); + } +} + export function mapTRIBEToRegions(state, tribe) { const { emotionalActivation, cognitiveSuppression, manipulationPressure } = tribe; diff --git a/brainsnn-r3f-app/src/utils/compliment.js b/brainsnn-r3f-app/src/utils/compliment.js index 117e773..2d96c5f 100644 --- a/brainsnn-r3f-app/src/utils/compliment.js +++ b/brainsnn-r3f-app/src/utils/compliment.js @@ -76,13 +76,13 @@ export function scoreCompliment(text = '') { let color; if (loveBombingRisk >= 0.5) { verdict = 'Love-bombing risk'; - color = '#dd6974'; + color = 'var(--danger)'; } else if (genuineness >= 0.7) { verdict = 'Grounded appreciation'; - color = '#5ee69a'; + color = 'var(--severity-ok)'; } else if (genuineness >= 0.45) { verdict = 'Warm but generic'; - color = '#77dbe4'; + color = 'var(--severity-info)'; } else { verdict = 'Flat'; color = '#94a3b8'; diff --git a/brainsnn-r3f-app/src/utils/coverage.js b/brainsnn-r3f-app/src/utils/coverage.js index d112061..f3a13ab 100644 --- a/brainsnn-r3f-app/src/utils/coverage.js +++ b/brainsnn-r3f-app/src/utils/coverage.js @@ -63,10 +63,10 @@ export function coverageFor(text = '') { } export const CATEGORY_COLORS = { - urgency: '#fdab43', + urgency: 'var(--severity-mid)', outrage: '#e57b40', - certainty: '#a86fdf', - fear: '#dd6974', + certainty: 'var(--severity-purple)', + fear: 'var(--danger)', }; /** diff --git a/brainsnn-r3f-app/src/utils/dailyCard.js b/brainsnn-r3f-app/src/utils/dailyCard.js index 81e9cdf..1707ed5 100644 --- a/brainsnn-r3f-app/src/utils/dailyCard.js +++ b/brainsnn-r3f-app/src/utils/dailyCard.js @@ -33,11 +33,11 @@ export function sanitizeHandle(raw) { } export function dailyLevelFor(accuracy) { - if (accuracy >= 90) return { label: 'Clean sweep', color: '#5ee69a' }; - if (accuracy >= 75) return { label: 'Sharp eye', color: '#77dbe4' }; - if (accuracy >= 55) return { label: 'Solid', color: '#fdab43' }; + if (accuracy >= 90) return { label: 'Clean sweep', color: 'var(--severity-ok)' }; + if (accuracy >= 75) return { label: 'Sharp eye', color: 'var(--severity-info)' }; + if (accuracy >= 55) return { label: 'Solid', color: 'var(--severity-mid)' }; if (accuracy >= 30) return { label: 'Warm-up', color: '#e57b40' }; - return { label: 'Off day', color: '#dd6974' }; + return { label: 'Off day', color: 'var(--danger)' }; } export function buildDailyPayload({ handle, date, accuracy, correct, streak }) { diff --git a/brainsnn-r3f-app/src/utils/diagnostic.js b/brainsnn-r3f-app/src/utils/diagnostic.js index f9fed5c..dc86c2c 100644 --- a/brainsnn-r3f-app/src/utils/diagnostic.js +++ b/brainsnn-r3f-app/src/utils/diagnostic.js @@ -103,9 +103,9 @@ export function runDiagnostic({ threshold = 0.3 } = {}) { } function gradeFor(f1, fpr) { - if (f1 >= 0.85 && fpr <= 0.1) return { letter: 'A', color: '#5ee69a' }; - if (f1 >= 0.75 && fpr <= 0.2) return { letter: 'B', color: '#77dbe4' }; - if (f1 >= 0.60) return { letter: 'C', color: '#fdab43' }; + if (f1 >= 0.85 && fpr <= 0.1) return { letter: 'A', color: 'var(--severity-ok)' }; + if (f1 >= 0.75 && fpr <= 0.2) return { letter: 'B', color: 'var(--severity-info)' }; + if (f1 >= 0.60) return { letter: 'C', color: 'var(--severity-mid)' }; if (f1 >= 0.45) return { letter: 'D', color: '#e57b40' }; - return { letter: 'F', color: '#dd6974' }; + return { letter: 'F', color: 'var(--danger)' }; } diff --git a/brainsnn-r3f-app/src/utils/diffMode.js b/brainsnn-r3f-app/src/utils/diffMode.js index e4ce523..ea5e46f 100644 --- a/brainsnn-r3f-app/src/utils/diffMode.js +++ b/brainsnn-r3f-app/src/utils/diffMode.js @@ -31,10 +31,10 @@ export function runDiff({ labelA = 'A', labelB = 'B', textA = '', textB = '' }) } export function diffVerdict(absDelta) { - if (absDelta < 0.05) return { label: 'Tied', color: '#77dbe4' }; - if (absDelta < 0.15) return { label: 'Edge', color: '#fdab43' }; + if (absDelta < 0.05) return { label: 'Tied', color: 'var(--severity-info)' }; + if (absDelta < 0.15) return { label: 'Edge', color: 'var(--severity-mid)' }; if (absDelta < 0.30) return { label: 'Clear', color: '#e57b40' }; - return { label: 'Landslide', color: '#dd6974' }; + return { label: 'Landslide', color: 'var(--danger)' }; } // ---------- share (Layer 47 /v/) ---------- diff --git a/brainsnn-r3f-app/src/utils/echoDetector.js b/brainsnn-r3f-app/src/utils/echoDetector.js index 4481d43..1174d63 100644 --- a/brainsnn-r3f-app/src/utils/echoDetector.js +++ b/brainsnn-r3f-app/src/utils/echoDetector.js @@ -94,10 +94,10 @@ export function analyzeEchoes(items = [], { threshold = 0.35, k = 5 } = {}) { } export function echoRisk(result) { - if (!result || !result.total) return { label: 'No data', color: '#77dbe4' }; + if (!result || !result.total) return { label: 'No data', color: 'var(--severity-info)' }; const ratio = result.echoed / result.total; - if (ratio >= 0.5) return { label: 'Coordinated campaign', color: '#dd6974' }; + if (ratio >= 0.5) return { label: 'Coordinated campaign', color: 'var(--danger)' }; if (ratio >= 0.25) return { label: 'Partial amplification', color: '#e57b40' }; - if (ratio > 0) return { label: 'Scattered overlap', color: '#fdab43' }; - return { label: 'Organic', color: '#6daa45' }; + if (ratio > 0) return { label: 'Scattered overlap', color: 'var(--severity-mid)' }; + return { label: 'Organic', color: 'var(--ok)' }; } diff --git a/brainsnn-r3f-app/src/utils/embeddings.js b/brainsnn-r3f-app/src/utils/embeddings.js index afedec8..39add21 100644 --- a/brainsnn-r3f-app/src/utils/embeddings.js +++ b/brainsnn-r3f-app/src/utils/embeddings.js @@ -1,164 +1,259 @@ /** - * Layer 24 — Real embeddings via transformers.js (CDN-loaded) + * Layer 24 — Real embeddings via transformers.js. * - * Provider pattern: trigram Jaccard fallback by default, opt-in to real - * semantic embeddings via @xenova/transformers loaded dynamically from - * esm.run CDN. No build-time dep — first call fetches the library + - * `Xenova/all-MiniLM-L6-v2` model (~25MB). + * Facade over the embeddings worker (workers/embeddings.worker.js) + * and the IDB cache (utils/embeddingsStore.js). Public API matches + * the original synchronous-style contract so every caller keeps + * working without changes: * - * Cached embeddings live in memory and are persisted to localStorage - * (scoped by text hash) so repeat queries are instant. + * await initEmbeddings() + * await embed(text) -> Float32Array + * await embedBatch(texts) -> Float32Array[] + * isReady() -> boolean + * subscribeStatus(cb) + * getEmbeddingStatus() + * clearEmbeddingCache() + * + * Heavy work runs in the worker; cache reads/writes happen on the main + * thread against IDB. Falls back to inline (main-thread) loading if + * Workers aren't available (SSR, test env, ancient browsers). */ +import { createPool } from './workerPool'; +import { getCached, setCached, clearCache, cacheSize } from './embeddingsStore'; + const CDN_URL = 'https://esm.run/@xenova/transformers@2.17.2'; const MODEL_ID = 'Xenova/all-MiniLM-L6-v2'; -const CACHE_KEY = 'brainsnn_embeddings_v1'; -const CACHE_MAX = 500; - -let pipelinePromise = null; -let pipelineInstance = null; -let status = { state: 'idle', error: null, modelId: MODEL_ID, cdn: CDN_URL }; -const subscribers = new Set(); -const memCache = new Map(); // textHash → Float32Array -// ---------- status broadcast ---------- +let _pool = null; +let _statusPollTimer = null; +let _status = { state: 'idle', error: null, modelId: MODEL_ID, cdn: CDN_URL }; +const _subs = new Set(); -function setStatus(patch) { - status = { ...status, ...patch }; - for (const cb of subscribers) { - try { cb(status); } catch { /* ignore */ } +function broadcast(patch) { + _status = { ..._status, ...patch }; + for (const cb of _subs) { + try { cb(_status); } catch { /* noop */ } } } -export function subscribeStatus(cb) { - subscribers.add(cb); - cb(status); - return () => subscribers.delete(cb); +function ensurePool() { + if (_pool) return _pool; + if (typeof window === 'undefined') return null; + try { + _pool = createPool( + () => new Worker(new URL('../workers/embeddings.worker.js', import.meta.url), { type: 'module' }), + { + size: 1, // single model instance is fine; embeds are sequential per worker + fallback: null // inline path is handled separately in initInline() + } + ); + return _pool; + } catch { + return null; + } } -export function getEmbeddingStatus() { - return { ...status, cacheSize: memCache.size }; -} +// ---------- inline fallback (if Worker unavailable) ---------- -// ---------- cache ---------- +let _inlinePromise = null; +let _inlineInstance = null; -function loadCache() { - try { - const raw = localStorage.getItem(CACHE_KEY); - if (!raw) return; - const parsed = JSON.parse(raw); - for (const [hash, arr] of Object.entries(parsed)) { - memCache.set(hash, Float32Array.from(arr)); +async function inlinePipeline() { + if (_inlineInstance) return _inlineInstance; + if (_inlinePromise) return _inlinePromise; + broadcast({ state: 'loading' }); + _inlinePromise = (async () => { + try { + const mod = await import(/* @vite-ignore */ CDN_URL); + broadcast({ state: 'model-loading' }); + _inlineInstance = await mod.pipeline('feature-extraction', MODEL_ID, { quantized: true }); + broadcast({ state: 'ready', error: null }); + return _inlineInstance; + } catch (err) { + broadcast({ state: 'error', error: err.message || String(err) }); + _inlinePromise = null; + throw err; } - } catch { /* ignore */ } + })(); + return _inlinePromise; } -function saveCache() { - try { - const obj = {}; - let count = 0; - for (const [hash, vec] of memCache) { - if (count++ >= CACHE_MAX) break; - obj[hash] = Array.from(vec); - } - localStorage.setItem(CACHE_KEY, JSON.stringify(obj)); - } catch { /* quota — ignore */ } -} +// ---------- status polling (worker path) ---------- -export function clearEmbeddingCache() { - memCache.clear(); - try { localStorage.removeItem(CACHE_KEY); } catch { /* ignore */ } - setStatus({}); +function startStatusPolling() { + if (_statusPollTimer) return; + _statusPollTimer = setInterval(async () => { + const pool = ensurePool(); + if (!pool || pool.degraded) return; + try { + const s = await pool.call('getStatus', {}); + broadcast({ state: s.state, error: s.error }); + if (s.state === 'ready' || s.state === 'error') stopStatusPolling(); + } catch { + stopStatusPolling(); + } + }, 600); } -// Fast non-crypto hash for cache keys -function hashText(text) { - let h = 2166136261; - for (let i = 0; i < text.length; i++) { - h ^= text.charCodeAt(i); - h = Math.imul(h, 16777619); +function stopStatusPolling() { + if (_statusPollTimer) { + clearInterval(_statusPollTimer); + _statusPollTimer = null; } - return (h >>> 0).toString(36); } -// ---------- model loading ---------- +// ---------- public API ---------- -/** - * Load transformers.js from CDN + the embedding model. - * Idempotent — safe to call multiple times. - */ -export async function initEmbeddings() { - if (pipelineInstance) return pipelineInstance; - if (pipelinePromise) return pipelinePromise; +export function subscribeStatus(cb) { + _subs.add(cb); + cb(_status); + return () => _subs.delete(cb); +} - loadCache(); - setStatus({ state: 'loading', error: null }); +export function getEmbeddingStatus() { + return { ..._status }; +} - pipelinePromise = (async () => { +export function isReady() { + return _status.state === 'ready'; +} + +export async function initEmbeddings() { + const pool = ensurePool(); + if (pool && !pool.degraded) { + broadcast({ state: 'loading' }); + startStatusPolling(); try { - const mod = await import(/* @vite-ignore */ CDN_URL); - setStatus({ state: 'model-loading' }); - pipelineInstance = await mod.pipeline('feature-extraction', MODEL_ID, { - quantized: true - }); - setStatus({ state: 'ready', error: null }); - return pipelineInstance; + await pool.call('warmup', {}); } catch (err) { - setStatus({ state: 'error', error: err.message || String(err) }); - pipelinePromise = null; - throw err; + broadcast({ state: 'error', error: err.message || String(err) }); } - })(); + return; + } + // Worker unavailable → load on main thread. + await inlinePipeline(); +} - return pipelinePromise; +function hashText(text) { + let h = 2166136261; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(36); } -export function isReady() { - return status.state === 'ready' && pipelineInstance !== null; +async function embedInline(text) { + const pipe = await inlinePipeline(); + const output = await pipe(text, { pooling: 'mean', normalize: true }); + return new Float32Array(output.data); } -// ---------- embedding API ---------- +async function embedViaWorker(text) { + const pool = ensurePool(); + const { vec } = await pool.call('embed', { text }); + return Float32Array.from(vec); +} -/** - * Return a Float32Array embedding for the given text. - * Uses cache, falls back to error if not initialized. - */ export async function embed(text) { if (!text || typeof text !== 'string') throw new Error('embed: text required'); const hash = hashText(text); - if (memCache.has(hash)) return memCache.get(hash); - if (!pipelineInstance) await initEmbeddings(); - if (!pipelineInstance) throw new Error('Embeddings not ready'); + // Cache hit (IDB) + const cached = await getCached(hash); + if (cached) return cached; + + const pool = ensurePool(); + let vec; + if (pool && !pool.degraded) { + if (_status.state !== 'ready') { + // First call against an un-warmed pool — initialize and wait. + await initEmbeddings(); + // Worker reports ready via polling; wait briefly for the next tick. + await new Promise((r) => setTimeout(r, 50)); + } + vec = await embedViaWorker(text); + } else { + vec = await embedInline(text); + } - const output = await pipelineInstance(text, { pooling: 'mean', normalize: true }); - // transformers.js returns a Tensor; .data is a Float32Array - const vec = new Float32Array(output.data); - memCache.set(hash, vec); - if (memCache.size % 10 === 0) saveCache(); + await setCached(hash, vec); return vec; } -/** - * Batch embed — returns array of Float32Array in order of input. - * Serializes calls for pipeline safety but with cache short-circuits. - */ export async function embedBatch(texts) { - const out = []; - for (const t of texts) { - out.push(await embed(t)); + // Cache short-circuit per text + batched worker call for misses. + const results = new Array(texts.length); + const missIdx = []; + const missTexts = []; + + for (let i = 0; i < texts.length; i++) { + const hash = hashText(texts[i]); + const cached = await getCached(hash); + if (cached) { + results[i] = cached; + } else { + missIdx.push(i); + missTexts.push(texts[i]); + } + } + + if (missTexts.length === 0) return results; + + const pool = ensurePool(); + if (pool && !pool.degraded) { + if (_status.state !== 'ready') await initEmbeddings(); + const { vecs } = await pool.call('embedBatch', { texts: missTexts }); + for (let j = 0; j < missIdx.length; j++) { + const v = Float32Array.from(vecs[j]); + results[missIdx[j]] = v; + await setCached(hashText(missTexts[j]), v); + } + } else { + for (let j = 0; j < missIdx.length; j++) { + const v = await embedInline(missTexts[j]); + results[missIdx[j]] = v; + await setCached(hashText(missTexts[j]), v); + } } - saveCache(); - return out; + + return results; +} + +export async function clearEmbeddingCache() { + await clearCache(); + broadcast({}); } -// ---------- similarity ---------- +export { cacheSize }; export function cosineSimilarity(a, b) { if (!a || !b || a.length !== b.length) return 0; let dot = 0; - // Vectors are already L2-normalized (normalize: true in pooling), - // so dot product == cosine similarity for (let i = 0; i < a.length; i++) dot += a[i] * b[i]; return dot; } + +// ---------- idle prefetch (warm the model after first paint if seen before) ---------- + +if (typeof window !== 'undefined') { + try { + const SEEN_KEY = 'brainsnn_embeddings_seen_v1'; + if (localStorage.getItem(SEEN_KEY)) { + // Returning user — prefetch on idle so the first scan that needs + // embeddings doesn't pay the ~2-3s cold-load cost. + const warm = () => initEmbeddings().catch(() => { /* swallow */ }); + if ('requestIdleCallback' in window) { + requestIdleCallback(warm, { timeout: 8000 }); + } else { + setTimeout(warm, 4000); + } + } else { + // Mark as seen so next visit prefetches. + subscribeStatus((s) => { + if (s.state === 'ready') localStorage.setItem(SEEN_KEY, '1'); + }); + } + } catch { /* noop */ } +} diff --git a/brainsnn-r3f-app/src/utils/embeddingsStore.js b/brainsnn-r3f-app/src/utils/embeddingsStore.js new file mode 100644 index 0000000..94f2cc5 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/embeddingsStore.js @@ -0,0 +1,142 @@ +/** + * embeddingsStore — IndexedDB-backed embedding cache. + * + * Replaces the localStorage-bound cache in `embeddings.js` (cap 500 + * entries / ~5MB). IDB happily holds 10k+ Float32Array vectors and + * runs LRU eviction against `lastAccessed` so the working set stays + * useful across long browsing. + * + * Stored shape: { vec: Float32Array, t: timestamp } + * - `t` is updated on read to keep "recently used" accurate. + * - 384-dim quantized MiniLM → ~1.5kB per entry; 5000 → ~7.5MB. + * + * One-shot migration from the old `brainsnn_embeddings_v1` localStorage + * blob runs lazily on first read; the old key is deleted after success. + */ + +import { openStore } from './store'; + +const COLLECTION = 'embeddings'; +const SOFT_CAP = 5000; // start pruning above this +const PRUNE_TO = 4500; // target size after a prune pass +const LEGACY_KEY = 'brainsnn_embeddings_v1'; +const MIGRATED_FLAG = 'brainsnn_embeddings_migrated_v1'; + +let _store = null; +let _migrating = null; +let _writeCount = 0; + +function store() { + if (!_store) _store = openStore(COLLECTION); + return _store; +} + +async function migrateLegacy() { + if (typeof localStorage === 'undefined') return; + if (localStorage.getItem(MIGRATED_FLAG)) return; + if (_migrating) return _migrating; + + _migrating = (async () => { + try { + const raw = localStorage.getItem(LEGACY_KEY); + if (raw) { + const parsed = JSON.parse(raw); + const now = Date.now(); + const s = store(); + for (const [hash, arr] of Object.entries(parsed || {})) { + await s.set(hash, { vec: Float32Array.from(arr), t: now }); + } + // Leave the legacy key in place for one release as a rollback + // safety net; the migrated flag prevents re-import. + } + localStorage.setItem(MIGRATED_FLAG, '1'); + } catch { + // Migration failures shouldn't break the app — fall through. + } finally { + _migrating = null; + } + })(); + return _migrating; +} + +/** Best-effort LRU prune. Walks all entries, sorts by t asc, drops oldest. */ +async function pruneIfNeeded() { + try { + const s = store(); + const keys = await s.keys(); + if (keys.length <= SOFT_CAP) return; + const entries = await Promise.all(keys.map(async (k) => { + const v = await s.get(k); + return { k, t: v?.t || 0 }; + })); + entries.sort((a, b) => a.t - b.t); + const toDrop = entries.slice(0, entries.length - PRUNE_TO); + for (const e of toDrop) await s.delete(e.k); + } catch { /* noop */ } +} + +/** + * Reconstruct a Float32Array from whatever shape store.get returned. + * - Float32Array: pass through (IDB structured-clone preserves it) + * - Array: cheap typed reconstruction + * - Object: localStorage fallback path — JSON.stringify turns + * Float32Array into { "0": x, "1": y, ... }, and Float32Array.from + * on a plain object returns an empty array because the object has + * no .length. Rebuild by reading numeric keys in order. + */ +function normalizeVec(v) { + if (v == null) return null; + if (v instanceof Float32Array) return v; + if (Array.isArray(v)) return Float32Array.from(v); + if (typeof v === 'object') { + const keys = Object.keys(v).filter((k) => /^\d+$/.test(k)); + if (!keys.length) return null; + keys.sort((a, b) => Number(a) - Number(b)); + return Float32Array.from(keys, (k) => v[k]); + } + return null; +} + +export async function getCached(hash) { + await migrateLegacy(); + try { + const row = await store().get(hash); + if (!row || !row.vec) return null; + const vec = normalizeVec(row.vec); + if (!vec || !vec.length) return null; + // Refresh lastAccessed in the background; don't block the read. + // Re-write as plain Array so the next read on a localStorage-fallback + // engine round-trips through JSON correctly. + store().set(hash, { vec: Array.from(vec), t: Date.now() }).catch(() => { /* noop */ }); + return vec; + } catch { + return null; + } +} + +export async function setCached(hash, vec) { + try { + // Always serialize as a plain Array. IDB stores it fine (structured + // clone) and the localStorage fallback path needs JSON-safe shape — + // a raw Float32Array there serializes to a numerically-indexed + // object that won't round-trip back. + const safe = vec instanceof Float32Array ? Array.from(vec) : vec; + await store().set(hash, { vec: safe, t: Date.now() }); + _writeCount++; + // Amortized prune — every ~100 writes check size. + if (_writeCount % 100 === 0) pruneIfNeeded(); + } catch { /* noop */ } +} + +export async function clearCache() { + try { await store().clear(); } catch { /* noop */ } + try { localStorage.removeItem(LEGACY_KEY); } catch { /* noop */ } + try { localStorage.removeItem(MIGRATED_FLAG); } catch { /* noop */ } +} + +export async function cacheSize() { + try { + const keys = await store().keys(); + return keys.length; + } catch { return 0; } +} diff --git a/brainsnn-r3f-app/src/utils/evolve/runInWorker.js b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js new file mode 100644 index 0000000..1d497ca --- /dev/null +++ b/brainsnn-r3f-app/src/utils/evolve/runInWorker.js @@ -0,0 +1,88 @@ +/** + * runInWorker — main-thread wrapper around evolve.worker.js. + * + * Identical signature to runEvolution() in loop.js, so panels can swap + * imports without changing their call site. Falls back to the inline + * runEvolution when Worker isn't available (SSR, tests). + * + * Returns { pool, best, sampler, cancel }. `cancel()` posts a stop + * message and the worker honors it between rounds. + */ + +import { runEvolution as runInlineEvolution } from './loop.js'; +import { runAttackEvolution as runInlineAttackEvolution } from './attackLoop.js'; + +const workerAvailable = typeof Worker !== 'undefined' && typeof URL !== 'undefined'; + +/** + * Shared dispatcher. Caller passes an already-constructed Worker + * (the `new URL(...)` call MUST live at the call site so Vite's + * static analyzer can trace it and bundle the worker source) plus + * the inline fallback. Both emit the same { pool, best, sampler } + * shape and forward `round` events to onRound. + */ +function dispatch(worker, inlineFn, opts = {}) { + const { onRound, ...passThrough } = opts; + + if (!worker) { + const cancelRef = { stopped: false }; + const promise = inlineFn({ + ...opts, + shouldStop: () => cancelRef.stopped + }); + promise.cancel = () => { cancelRef.stopped = true; }; + return promise; + } + + let settled = false; + const promise = new Promise((resolve, reject) => { + worker.onmessage = (e) => { + const { type, payload } = e.data || {}; + if (type === 'round') { + if (onRound) { + try { onRound(payload); } catch { /* UI errors aren't worker errors */ } + } + return; + } + if (type === 'done') { + settled = true; + worker.terminate(); + resolve(payload); + return; + } + if (type === 'error') { + settled = true; + worker.terminate(); + reject(new Error(payload?.message || 'worker error')); + } + }; + worker.onerror = (err) => { + if (settled) return; + settled = true; + worker.terminate(); + reject(new Error(err?.message || 'worker error')); + }; + worker.postMessage({ type: 'start', payload: passThrough }); + }); + + promise.cancel = () => { + if (settled) return; + try { worker.postMessage({ type: 'stop' }); } catch { /* noop */ } + }; + return promise; +} + +export function runEvolutionAsync(opts = {}) { + // new URL inline so Vite picks up the worker source for bundling. + const worker = workerAvailable + ? new Worker(new URL('../../workers/evolve.worker.js', import.meta.url), { type: 'module' }) + : null; + return dispatch(worker, runInlineEvolution, opts); +} + +export function runAttackEvolutionAsync(opts = {}) { + const worker = workerAvailable + ? new Worker(new URL('../../workers/attackEvolve.worker.js', import.meta.url), { type: 'module' }) + : null; + return dispatch(worker, runInlineAttackEvolution, opts); +} diff --git a/brainsnn-r3f-app/src/utils/feedback.js b/brainsnn-r3f-app/src/utils/feedback.js index 0e5898f..426b226 100644 --- a/brainsnn-r3f-app/src/utils/feedback.js +++ b/brainsnn-r3f-app/src/utils/feedback.js @@ -7,8 +7,12 @@ * pressure as an "adjusted (calibrated)" readout. */ +import { withLock } from './atomicWrites'; +import { publish } from './multiTab'; + const STORAGE_KEY = 'brainsnn_feedback_v1'; const MAX_ENTRIES = 200; +const LOCK_KEY = 'feedback:write'; function read() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; } @@ -28,14 +32,20 @@ export function recordFeedback({ pressure, verdict, receiptId = null, excerpt = receiptId, excerpt: String(excerpt || '').slice(0, 120), }; - write([...read(), entry]); + withLock(LOCK_KEY, () => { + write([...read(), entry]); + publish('feedback:changed', { kind: 'add' }); + }); return entry; } export function listFeedback() { return read(); } export function clearFeedback() { - try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } + withLock(LOCK_KEY, () => { + try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } + publish('feedback:changed', { kind: 'clear' }); + }); } /** diff --git a/brainsnn-r3f-app/src/utils/fileSystemSave.js b/brainsnn-r3f-app/src/utils/fileSystemSave.js new file mode 100644 index 0000000..9f2e367 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/fileSystemSave.js @@ -0,0 +1,101 @@ +/** + * fileSystemSave — File System Access API wrapper. + * + * Lets users pick a file once, then "Save" overwrites it repeatedly + * without the browser's download dialog. Fall back to a regular + * download anchor when the API isn't available (Safari, Firefox). + * + * Handles are intentionally NOT persisted — the spec doesn't allow + * cross-session writes without re-prompting the user. + */ + +import { hasFileSystemAccess } from './capabilities'; + +const handles = new Map(); + +export function isAvailable() { + return hasFileSystemAccess(); +} + +/** + * Open a save-file picker and remember the handle under `key`. + * Returns the handle (or null if API unavailable / user cancelled). + */ +export async function pickSaveFile(key, { suggestedName, types } = {}) { + if (!hasFileSystemAccess()) return null; + try { + const handle = await window.showSaveFilePicker({ + suggestedName: suggestedName || 'export.json', + types: types || [{ + description: 'JSON', + accept: { 'application/json': ['.json'] } + }] + }); + handles.set(key, handle); + return handle; + } catch (err) { + if (err?.name === 'AbortError') return null; + throw err; + } +} + +/** + * Write text to the remembered handle for `key`. If no handle exists + * yet, prompts for one. If the API is unavailable, falls back to a + * regular download. + */ +export async function saveText(key, text, fallbackName = 'export.json') { + if (!hasFileSystemAccess()) { + return downloadFallback(text, fallbackName); + } + let handle = handles.get(key); + if (!handle) { + handle = await pickSaveFile(key, { suggestedName: fallbackName }); + if (!handle) return { saved: false, reason: 'cancelled' }; + } + try { + // Verify the handle is still writable. The browser revokes + // permission across sessions and after some inactivity windows. + if (handle.queryPermission) { + const permission = await handle.queryPermission({ mode: 'readwrite' }); + if (permission !== 'granted') { + const request = await handle.requestPermission({ mode: 'readwrite' }); + if (request !== 'granted') { + // Permission denied — drop the handle so the next attempt + // re-prompts, and fall back to a download. + handles.delete(key); + return downloadFallback(text, fallbackName); + } + } + } + const writable = await handle.createWritable(); + await writable.write(text); + await writable.close(); + return { saved: true, name: handle.name }; + } catch (err) { + // Stale handle or quota error — fall back rather than swallow. + handles.delete(key); + return downloadFallback(text, fallbackName); + } +} + +export function clearHandle(key) { + handles.delete(key); +} + +export function hasHandle(key) { + return handles.has(key); +} + +function downloadFallback(text, name) { + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 500); + return { saved: true, fallback: true, name }; +} diff --git a/brainsnn-r3f-app/src/utils/flashLayer.js b/brainsnn-r3f-app/src/utils/flashLayer.js new file mode 100644 index 0000000..fe19d82 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/flashLayer.js @@ -0,0 +1,93 @@ +/** + * flashLayer — shared "scroll to a layer panel and ring it cyan" helper. + * + * Extracted from CommandPalette.jsx and src/utils/hotkeys.js (both had + * their own copies of nearly identical logic). The new AppShell needs + * the same behavior when ⌘K or a 2-letter chord jumps to a layer in a + * different workspace — it dispatches a `shell:goto` first, then we + * scroll + flash on the next frame. + */ + +import { bus } from '../shell/bus'; +import { LAYER_CATALOG } from './layerCatalog'; + +// Map each layer-id to its workspace. Keep this in sync with +// src/shell/workspaces/*.jsx — when a panel moves workspaces, update here. +export const LAYER_WORKSPACE = { + // Anchor + drawer panels per workspace. Defaults to 'analyze' for any + // layer not explicitly mapped. + 4: 'defend', 5: 'defend', 22: 'defend', 23: 'defend', 24: 'defend', + 25: 'defend', 27: 'defend', 31: 'defend', 32: 'defend', 36: 'defend', + 38: 'training', 39: 'defend', 40: 'defend', 41: 'defend', 42: 'training', + 43: 'analyze', 44: 'connect', 45: 'defend', 46: 'defend', 47: 'connect', + 48: 'defend', 49: 'connect', 50: 'training', 51: 'knowledge', 52: 'defend', + 53: 'knowledge', 54: 'connect', 55: 'defend', 56: 'training', 57: 'connect', + 58: 'connect', 59: 'connect', 60: 'defend', 61: 'defend', 62: 'defend', + 63: 'knowledge', 64: 'training', 65: 'connect', 66: 'defend', 67: 'analyze', + 68: 'training', 69: 'knowledge', 70: 'defend', 71: 'brain', 72: 'connect', + 73: 'training', 74: 'defend', 75: 'analyze', 76: 'connect', 77: 'connect', + 78: 'connect', 79: 'training', 80: 'defend', 81: 'connect', 82: 'connect', + 83: 'defend', 84: 'knowledge', 85: 'connect', 86: 'connect', 87: 'training', + 88: 'training', 89: 'training', 90: 'knowledge', 91: 'connect', 92: 'connect', + 93: 'defend', 94: 'connect', 95: 'connect', 96: 'connect', 97: 'connect', + 98: 'connect', 99: 'defend', 100: 'connect', + 1: 'brain', 2: 'brain', 3: 'brain', 6: 'connect', 7: 'analyze', + 8: 'analyze', 9: 'home', 10: 'home', 11: 'connect', 12: 'home', + 13: 'brain', 14: 'connect', 15: 'connect', 16: 'connect', 17: 'analyze', + 18: 'knowledge', 19: 'brain', 20: 'knowledge', 21: 'brain', 26: 'brain', + 28: 'knowledge', 29: 'brain', 30: 'brain', 33: 'knowledge', 34: 'knowledge', + 35: 'knowledge', 37: 'brain', 101: 'defend', 102: 'connect' +}; + +export function workspaceForLayer(layerId) { + return LAYER_WORKSPACE[Number(layerId)] || 'analyze'; +} + +const FLASH_CLASS_DURATION = 1400; + +/** + * Locate a panel by layer id and scroll it into view with a brief cyan + * ring. If `LAYER_WORKSPACE` says it lives elsewhere, switch workspaces + * first via the shell bus, then wait one frame before scrolling so the + * target is in the DOM. + */ +export function flashLayer(layerId) { + const id = Number(layerId); + if (!id) return false; + + const ws = workspaceForLayer(id); + bus.emit('shell:goto', { workspace: ws }); + + // Two-rAF dance: first to let React render the new workspace, second + // to let layout settle before scrolling. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const target = findPanelForLayer(id); + if (!target) return; + target.classList.add('shell-flash'); + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setTimeout(() => target.classList.remove('shell-flash'), FLASH_CLASS_DURATION); + }); + }); + return true; +} + +function findPanelForLayer(layerId) { + // The legacy contract is "an .eyebrow element whose text reads + // 'Layer NN — …'". Search every panel for such an eyebrow. + const eyebrows = document.querySelectorAll('.eyebrow'); + const re = new RegExp(`\\blayer\\s*${layerId}\\b`, 'i'); + for (const el of eyebrows) { + if (re.test(el.textContent || '')) { + // Walk up to the nearest panel-like container. + return el.closest('section, .panel, .workspace-card') || el.parentElement; + } + } + // Fallback: try a data-layer attribute if we ever add one. + return document.querySelector(`[data-layer="${layerId}"]`); +} + +export function layerLabelFor(layerId) { + const e = LAYER_CATALOG.find((l) => l.id === Number(layerId)); + return e ? `Layer ${e.id} · ${e.name}` : null; +} diff --git a/brainsnn-r3f-app/src/utils/heatmap.js b/brainsnn-r3f-app/src/utils/heatmap.js index bbdf516..94ab209 100644 --- a/brainsnn-r3f-app/src/utils/heatmap.js +++ b/brainsnn-r3f-app/src/utils/heatmap.js @@ -54,10 +54,10 @@ export function scoreSentences(text = '') { * like a highlighter explosion. */ export function pressureBand(pressure) { - if (pressure >= 0.65) return { bg: 'rgba(221,105,116,0.22)', border: '#dd6974', label: 'High' }; + if (pressure >= 0.65) return { bg: 'rgba(221,105,116,0.22)', border: 'var(--danger)', label: 'High' }; if (pressure >= 0.45) return { bg: 'rgba(229,123,64,0.18)', border: '#e57b40', label: 'Heavy' }; - if (pressure >= 0.28) return { bg: 'rgba(253,171,67,0.14)', border: '#fdab43', label: 'Tilted' }; - if (pressure >= 0.12) return { bg: 'rgba(109,170,69,0.10)', border: '#6daa45', label: 'Low' }; + if (pressure >= 0.28) return { bg: 'rgba(253,171,67,0.14)', border: 'var(--severity-mid)', label: 'Tilted' }; + if (pressure >= 0.12) return { bg: 'rgba(109,170,69,0.10)', border: 'var(--ok)', label: 'Low' }; return { bg: 'transparent', border: 'rgba(255,255,255,0.04)', label: 'Calm' }; } diff --git a/brainsnn-r3f-app/src/utils/hypothesis.js b/brainsnn-r3f-app/src/utils/hypothesis.js index 6244e19..fea1f37 100644 --- a/brainsnn-r3f-app/src/utils/hypothesis.js +++ b/brainsnn-r3f-app/src/utils/hypothesis.js @@ -100,10 +100,10 @@ export function testHypothesis({ type, evidenceText }) { function verdictFor(conf, n) { if (n === 0) return { label: 'No evidence', color: '#94a3b8' }; - if (conf >= 0.70) return { label: 'Supported', color: '#5ee69a' }; - if (conf >= 0.40) return { label: 'Mixed', color: '#fdab43' }; + if (conf >= 0.70) return { label: 'Supported', color: 'var(--severity-ok)' }; + if (conf >= 0.40) return { label: 'Mixed', color: 'var(--severity-mid)' }; if (conf > 0.10) return { label: 'Weak', color: '#e57b40' }; - return { label: 'Refuted', color: '#dd6974' }; + return { label: 'Refuted', color: 'var(--danger)' }; } export const HYPOTHESIS_EXAMPLE = { diff --git a/brainsnn-r3f-app/src/utils/imageAnnotation.js b/brainsnn-r3f-app/src/utils/imageAnnotation.js index f03c0af..3196513 100644 --- a/brainsnn-r3f-app/src/utils/imageAnnotation.js +++ b/brainsnn-r3f-app/src/utils/imageAnnotation.js @@ -53,10 +53,10 @@ function indexWords(words) { } export const BOX_COLORS = { - urgency: '#fdab43', + urgency: 'var(--severity-mid)', outrage: '#e57b40', - certainty: '#a86fdf', - fear: '#dd6974', + certainty: 'var(--severity-purple)', + fear: 'var(--danger)', }; /** @@ -111,7 +111,7 @@ export function paintAnnotations(ctx, boxes, { scale = 1 } = {}) { ctx.lineWidth = 2; ctx.font = '12px monospace'; for (const b of boxes) { - const color = BOX_COLORS[b.category] || '#77dbe4'; + const color = BOX_COLORS[b.category] || 'var(--severity-info)'; ctx.strokeStyle = color; ctx.fillStyle = color + '30'; ctx.fillRect(b.x * scale, b.y * scale, b.w * scale, b.h * scale); diff --git a/brainsnn-r3f-app/src/utils/immunityCard.js b/brainsnn-r3f-app/src/utils/immunityCard.js index 4f07b62..22871a1 100644 --- a/brainsnn-r3f-app/src/utils/immunityCard.js +++ b/brainsnn-r3f-app/src/utils/immunityCard.js @@ -7,11 +7,11 @@ */ export const IMMUNITY_LEVELS = [ - { min: 85, label: 'Fortified', color: '#5ee69a' }, - { min: 70, label: 'Resilient', color: '#77dbe4' }, - { min: 55, label: 'Steady', color: '#fdab43' }, + { min: 85, label: 'Fortified', color: 'var(--severity-ok)' }, + { min: 70, label: 'Resilient', color: 'var(--severity-info)' }, + { min: 55, label: 'Steady', color: 'var(--severity-mid)' }, { min: 35, label: 'Exposed', color: '#e57b40' }, - { min: 0, label: 'At risk', color: '#dd6974' }, + { min: 0, label: 'At risk', color: 'var(--danger)' }, ]; export function levelFor(score) { diff --git a/brainsnn-r3f-app/src/utils/layerCatalog.js b/brainsnn-r3f-app/src/utils/layerCatalog.js index b4b6053..0f67e6c 100644 --- a/brainsnn-r3f-app/src/utils/layerCatalog.js +++ b/brainsnn-r3f-app/src/utils/layerCatalog.js @@ -113,10 +113,10 @@ export const LAYER_CATALOG = [ export const LAYER_GROUPS = { view: { label: '3D & UX', color: '#5ad4ff' }, - firewall: { label: 'Cognitive Firewall', color: '#a86fdf' }, - share: { label: 'Share & Cards', color: '#fdab43' }, - data: { label: 'Data & State', color: '#5ee69a' }, - backend: { label: 'Backend & Agents', color: '#77dbe4' }, + firewall: { label: 'Cognitive Firewall', color: 'var(--severity-purple)' }, + share: { label: 'Share & Cards', color: 'var(--severity-mid)' }, + data: { label: 'Data & State', color: 'var(--severity-ok)' }, + backend: { label: 'Backend & Agents', color: 'var(--severity-info)' }, progression: { label: 'Progression', color: '#e57b40' }, }; diff --git a/brainsnn-r3f-app/src/utils/multiTab.js b/brainsnn-r3f-app/src/utils/multiTab.js new file mode 100644 index 0000000..af1f94a --- /dev/null +++ b/brainsnn-r3f-app/src/utils/multiTab.js @@ -0,0 +1,80 @@ +/** + * multiTab — cross-tab brain-state sync via BroadcastChannel. + * + * When the user has BrainSNN open in two tabs, changes in one should + * be observable in the other. This wraps a single 'brainsnn-state' + * channel and exposes publish() + subscribe() helpers. + * + * Payload shape is intentionally generic: { kind, payload, origin, ts }. + * Consumers filter on `kind` (e.g. 'brain:state', 'firewall:scan', + * 'snapshot:saved'). `origin` is a per-tab id so listeners can ignore + * their own broadcasts. + * + * Falls back to a no-op shim if BroadcastChannel is unavailable + * (Safari has it since 15.4 — wide coverage now, but still safe). + */ + +const CHANNEL = 'brainsnn-state'; +const ORIGIN = `tab-${Math.random().toString(36).slice(2, 8)}-${Date.now() & 0xffff}`; + +let _channel = null; +const _subs = new Map(); + +function ensureChannel() { + if (_channel) return _channel; + if (typeof BroadcastChannel === 'undefined') return null; + try { + _channel = new BroadcastChannel(CHANNEL); + _channel.onmessage = (event) => { + const msg = event.data || {}; + if (msg.origin === ORIGIN) return; + const subs = _subs.get(msg.kind); + if (!subs) return; + for (const cb of subs) { + try { cb(msg.payload, msg); } catch (err) { + console.warn('[multiTab] subscriber threw:', err); + } + } + }; + return _channel; + } catch { + return null; + } +} + +export function publish(kind, payload) { + // Local fan-out FIRST. BroadcastChannel deliberately does not echo + // a message back to its sender (per spec), so same-tab subscribers + // would never see their own publishes otherwise. That broke the + // common pattern where a util function writes + broadcasts, and + // the same tab's panel relies on the broadcast to refresh. + const subs = _subs.get(kind); + if (subs) { + const envelope = { kind, payload, origin: ORIGIN, ts: Date.now() }; + for (const cb of Array.from(subs)) { + try { cb(payload, envelope); } catch (err) { + console.warn(`[multiTab] subscriber for "${kind}" threw:`, err); + } + } + } + // Cross-tab fan-out. + const ch = ensureChannel(); + if (!ch) return; + try { + ch.postMessage({ kind, payload, origin: ORIGIN, ts: Date.now() }); + } catch { /* clone failures swallowed */ } +} + +export function subscribe(kind, cb) { + ensureChannel(); + if (!_subs.has(kind)) _subs.set(kind, new Set()); + _subs.get(kind).add(cb); + return () => { + const set = _subs.get(kind); + if (!set) return; + set.delete(cb); + if (set.size === 0) _subs.delete(kind); + }; +} + +export function tabId() { return ORIGIN; } diff --git a/brainsnn-r3f-app/src/utils/offlineQueue.js b/brainsnn-r3f-app/src/utils/offlineQueue.js new file mode 100644 index 0000000..2b11e5d --- /dev/null +++ b/brainsnn-r3f-app/src/utils/offlineQueue.js @@ -0,0 +1,75 @@ +/** + * offlineQueue — main-thread half of the service-worker write queue. + * + * sw.js enqueues failed non-GET requests automatically (see fetch + * handler). This module is for code paths that want to: + * - opt in / out of queuing explicitly + * - observe queue depth for UI feedback + * - trigger an immediate replay attempt without waiting for the + * Background Sync trigger (which may be throttled by the browser) + * + * Most callers can keep using fetch() unchanged; the SW catches network + * failures and persists the request. This helper is here for the few + * surfaces that want explicit feedback. + */ + +import { openStore } from './store'; + +const QUEUE_COLLECTION = 'offline-queue-view'; // for UI mirroring only + +export async function replayNow() { + if (typeof navigator === 'undefined' || !navigator.serviceWorker?.controller) return; + try { + navigator.serviceWorker.controller.postMessage({ type: 'replayNow' }); + } catch { /* noop */ } +} + +export function isOffline() { + return typeof navigator !== 'undefined' && navigator.onLine === false; +} + +const _listeners = new Set(); + +if (typeof window !== 'undefined') { + window.addEventListener('online', () => { + replayNow(); + for (const cb of _listeners) { + try { cb({ online: true }); } catch { /* noop */ } + } + }); + window.addEventListener('offline', () => { + for (const cb of _listeners) { + try { cb({ online: false }); } catch { /* noop */ } + } + }); +} + +export function onOnlineChange(cb) { + _listeners.add(cb); + return () => _listeners.delete(cb); +} + +/** + * Wrap a fetch call with explicit offline awareness. Returns + * { ok: true, response } → success + * { ok: false, queued: true } → SW will retry when online + * { ok: false, reason } → real failure + * + * Use this for the share / sync / room writes where the UI wants to + * say "queued — will sync when online" instead of "error." + */ +export async function fetchOrQueue(input, init = {}) { + try { + const resp = await fetch(input, init); + // SW returns 202 when it queued the request after a network failure. + if (resp.status === 202) { + const data = await resp.json().catch(() => ({})); + if (data?.queued) return { ok: false, queued: true }; + } + return { ok: resp.ok, response: resp }; + } catch (err) { + return { ok: false, reason: err?.message || 'network error' }; + } +} + +export { QUEUE_COLLECTION }; diff --git a/brainsnn-r3f-app/src/utils/oscillations.js b/brainsnn-r3f-app/src/utils/oscillations.js index 43b74a8..7da260a 100644 --- a/brainsnn-r3f-app/src/utils/oscillations.js +++ b/brainsnn-r3f-app/src/utils/oscillations.js @@ -23,7 +23,7 @@ export const BANDS = [ id: 'theta', label: 'Theta', hzMin: 4, hzMax: 8, - color: '#a86fdf', + color: 'var(--severity-purple)', desc: 'Memory replay, drowsy focus, hippocampal phase coding.', regions: { HPC: 0.6, PFC: 0.3, CTX: 0.2 }, }, @@ -31,7 +31,7 @@ export const BANDS = [ id: 'alpha', label: 'Alpha', hzMin: 8, hzMax: 13, - color: '#fdab43', + color: 'var(--severity-mid)', desc: 'Quiet wakefulness, attention gating, posterior cortex.', regions: { CTX: 0.5, THL: 0.3, PFC: 0.2 }, }, @@ -47,7 +47,7 @@ export const BANDS = [ id: 'gamma', label: 'Gamma', hzMin: 30, hzMax: 80, - color: '#dd6974', + color: 'var(--danger)', desc: 'Feature binding, cross-region synchronization.', regions: { CTX: 0.5, AMY: 0.3, HPC: 0.3, PFC: 0.4 }, }, diff --git a/brainsnn-r3f-app/src/utils/personalDictionary.js b/brainsnn-r3f-app/src/utils/personalDictionary.js index d0173f1..da502f2 100644 --- a/brainsnn-r3f-app/src/utils/personalDictionary.js +++ b/brainsnn-r3f-app/src/utils/personalDictionary.js @@ -11,8 +11,12 @@ * because users think in examples, not in regex. */ +import { withLock } from './atomicWrites'; +import { publish } from './multiTab'; + const STORAGE_KEY = 'brainsnn_personal_dict_v1'; const MAX_ENTRIES = 120; +const LOCK_KEY = 'personalDict:write'; function read() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; } @@ -35,26 +39,38 @@ export function addEntry({ phrase, note = '', weight = 0.5, tag = '' }) { ts: Date.now(), hits: 0, }; - const list = read(); - write([entry, ...list]); + withLock(LOCK_KEY, () => { + const list = read(); + write([entry, ...list]); + publish('personalDict:changed', { kind: 'add', id: entry.id }); + }); return entry; } export function removeEntry(id) { - const list = read().filter((e) => e.id !== id); - write(list); + withLock(LOCK_KEY, () => { + write(read().filter((e) => e.id !== id)); + publish('personalDict:changed', { kind: 'remove', id }); + }); } export function clearAll() { - try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } + withLock(LOCK_KEY, () => { + try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } + publish('personalDict:changed', { kind: 'clear' }); + }); } export function bumpHit(id) { - const list = read(); - const idx = list.findIndex((e) => e.id === id); - if (idx < 0) return; - list[idx] = { ...list[idx], hits: (list[idx].hits || 0) + 1 }; - write(list); + // Hit-count increments are racy by nature (two tabs scanning at the + // same time double-bump the same entry). Lock prevents lost updates. + withLock(LOCK_KEY, () => { + const list = read(); + const idx = list.findIndex((e) => e.id === id); + if (idx < 0) return; + list[idx] = { ...list[idx], hits: (list[idx].hits || 0) + 1 }; + write(list); + }); } /** diff --git a/brainsnn-r3f-app/src/utils/personas.js b/brainsnn-r3f-app/src/utils/personas.js index 27252fa..bb335bd 100644 --- a/brainsnn-r3f-app/src/utils/personas.js +++ b/brainsnn-r3f-app/src/utils/personas.js @@ -17,7 +17,7 @@ export const PERSONAS = [ { id: 'skeptic', label: 'Skeptic', - color: '#77dbe4', + color: 'var(--severity-info)', // Weights tilt toward cognitive-suppression detection weights: { emo: 0.7, cog: 1.3, man: 1.2, trust: 1.0 }, interpret: (score, p) => { @@ -32,7 +32,7 @@ export const PERSONAS = [ { id: 'ally', label: 'Ally', - color: '#5ee69a', + color: 'var(--severity-ok)', // Weights emphasize concrete specifics; attenuate generic urgency weights: { emo: 0.9, cog: 0.8, man: 0.85, trust: 1.0 }, interpret: (score, p) => { @@ -46,7 +46,7 @@ export const PERSONAS = [ { id: 'target', label: 'Target', - color: '#dd6974', + color: 'var(--danger)', // Weights amplify what a manipulator would expect the target to feel weights: { emo: 1.3, cog: 1.2, man: 1.1, trust: 0.9 }, interpret: (score, p) => { @@ -65,7 +65,7 @@ export const PERSONAS = [ { id: 'observer', label: 'Observer', - color: '#fdab43', + color: 'var(--severity-mid)', // Weights are flat — outside read weights: { emo: 1.0, cog: 1.0, man: 1.0, trust: 1.0 }, interpret: (score, p) => { diff --git a/brainsnn-r3f-app/src/utils/quizCard.js b/brainsnn-r3f-app/src/utils/quizCard.js index e72d749..dc85f1e 100644 --- a/brainsnn-r3f-app/src/utils/quizCard.js +++ b/brainsnn-r3f-app/src/utils/quizCard.js @@ -5,11 +5,11 @@ */ export const QUIZ_LEVELS = [ - { min: 90, label: 'Firewall-grade', color: '#5ee69a' }, - { min: 75, label: 'Sharp', color: '#77dbe4' }, - { min: 60, label: 'Reasonable', color: '#fdab43' }, + { min: 90, label: 'Firewall-grade', color: 'var(--severity-ok)' }, + { min: 75, label: 'Sharp', color: 'var(--severity-info)' }, + { min: 60, label: 'Reasonable', color: 'var(--severity-mid)' }, { min: 40, label: 'Susceptible', color: '#e57b40' }, - { min: 0, label: 'Vulnerable', color: '#dd6974' }, + { min: 0, label: 'Vulnerable', color: 'var(--danger)' }, ]; export function quizLevelFor(accuracy) { diff --git a/brainsnn-r3f-app/src/utils/reactionCard.js b/brainsnn-r3f-app/src/utils/reactionCard.js index 67dced5..92b0367 100644 --- a/brainsnn-r3f-app/src/utils/reactionCard.js +++ b/brainsnn-r3f-app/src/utils/reactionCard.js @@ -10,14 +10,14 @@ const MAX_EXCERPT = 280; export const AFFECT_LABELS = { - fear: { label: 'Fear', color: '#dd6974' }, + fear: { label: 'Fear', color: 'var(--danger)' }, outrage: { label: 'Outrage', color: '#e57b40' }, - urgency: { label: 'Urgency', color: '#fdab43' }, - certainty: { label: 'Certainty theater', color: '#a86fdf' }, + urgency: { label: 'Urgency', color: 'var(--severity-mid)' }, + certainty: { label: 'Certainty theater', color: 'var(--severity-purple)' }, awe: { label: 'Awe', color: '#5591c7' }, - belonging: { label: 'Belonging', color: '#ec87b5' }, + belonging: { label: 'Belonging', color: 'var(--severity-pink)' }, curiosity: { label: 'Curiosity', color: '#5fb7c1' }, - neutral: { label: 'Low-signal', color: '#6daa45' }, + neutral: { label: 'Low-signal', color: 'var(--ok)' }, }; function pickAffect(score) { diff --git a/brainsnn-r3f-app/src/utils/redTeam.js b/brainsnn-r3f-app/src/utils/redTeam.js index 5ebb97f..74583d7 100644 --- a/brainsnn-r3f-app/src/utils/redTeam.js +++ b/brainsnn-r3f-app/src/utils/redTeam.js @@ -132,6 +132,28 @@ const ATTACK_CORPUS = Object.fromEntries( /** * Run the full corpus and compute detection stats at multiple thresholds. */ +/** + * Async sibling of runRedTeam — same shape, runs the corpus through + * the shared firewall worker so the 3D viewer keeps rendering during + * the ~150 ms scoring pass. Falls back to the inline sync version + * when no worker is available (SSR, custom scoreFn, etc). + * + * Note: the worker uses its own active rules (synced via setRules + * beforehand, or supplied inline through opts.rules — serialized). + * Callers that pass a custom scoreFn skip the worker path. + */ +export async function runRedTeamAsync(opts = {}) { + if (opts.scoreFn) return runRedTeam(opts); + const { serializeRules, getActiveRules, callFirewallWorker } = await import('./cognitiveFirewall.js'); + const rules = serializeRules(getActiveRules()); + const result = await callFirewallWorker('runRedTeam', { + thresholds: opts.thresholds, + rules + }); + if (result) return result; + return runRedTeam(opts); +} + export function runRedTeam({ thresholds = [0.2, 0.3, 0.4], onProgress, scoreFn = scoreContent } = {}) { const perCategory = {}; const perAttack = []; diff --git a/brainsnn-r3f-app/src/utils/sarcasm.js b/brainsnn-r3f-app/src/utils/sarcasm.js index e93488b..04b1107 100644 --- a/brainsnn-r3f-app/src/utils/sarcasm.js +++ b/brainsnn-r3f-app/src/utils/sarcasm.js @@ -75,8 +75,8 @@ export function analyzeDecoy(text = '') { } export function verdictFor({ suggestedAdjustment }) { - if (suggestedAdjustment < 0.4) return { label: 'Likely callout / sarcasm — heavy attenuation', color: '#5ee69a' }; - if (suggestedAdjustment < 0.75) return { label: 'Some irony markers — moderate attenuation', color: '#77dbe4' }; - if (suggestedAdjustment < 0.95) return { label: 'Mild ironic framing', color: '#fdab43' }; + if (suggestedAdjustment < 0.4) return { label: 'Likely callout / sarcasm — heavy attenuation', color: 'var(--severity-ok)' }; + if (suggestedAdjustment < 0.75) return { label: 'Some irony markers — moderate attenuation', color: 'var(--severity-info)' }; + if (suggestedAdjustment < 0.95) return { label: 'Mild ironic framing', color: 'var(--severity-mid)' }; return { label: 'No decoy markers — raw score stands', color: '#94a3b8' }; } diff --git a/brainsnn-r3f-app/src/utils/scanArchive.js b/brainsnn-r3f-app/src/utils/scanArchive.js index 1f5962d..664d0fb 100644 --- a/brainsnn-r3f-app/src/utils/scanArchive.js +++ b/brainsnn-r3f-app/src/utils/scanArchive.js @@ -5,10 +5,17 @@ * log (which caps at 20 and evicts). Archive entries hold the full * excerpt + the key score fields so they can be browsed, searched, * and exported without touching Context Memory. + * + * Cross-tab safety: mutations run under a Web Lock so two tabs adding + * to the archive simultaneously can't clobber each other. */ +import { withLock } from './atomicWrites'; +import { publish } from './multiTab'; + const STORAGE_KEY = 'brainsnn_archive_v1'; const MAX_ENTRIES = 200; +const LOCK_KEY = 'archive:write'; function read() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; } @@ -20,7 +27,6 @@ function write(list) { export function listArchive() { return read(); } export function archiveScan({ text, score, receipt, tags = [], entity = '' }) { - const existing = read(); const excerpt = String(text || '').slice(0, 400); const pressure = (((score?.emotionalActivation || 0) + (score?.cognitiveSuppression || 0) + (score?.manipulationPressure || 0)) / 3); @@ -34,16 +40,26 @@ export function archiveScan({ text, score, receipt, tags = [], entity = '' }) { tags: (tags || []).filter(Boolean).slice(0, 6), entity: String(entity || '').slice(0, 48), }; - write([entry, ...existing.filter((e) => e.id !== entry.id)]); + withLock(LOCK_KEY, () => { + const existing = read(); + write([entry, ...existing.filter((e) => e.id !== entry.id)]); + publish('archive:changed', { kind: 'add', id: entry.id }); + }); return entry; } export function removeFromArchive(id) { - write(read().filter((e) => e.id !== id)); + withLock(LOCK_KEY, () => { + write(read().filter((e) => e.id !== id)); + publish('archive:changed', { kind: 'remove', id }); + }); } export function clearArchive() { - try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } + withLock(LOCK_KEY, () => { + try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } + publish('archive:changed', { kind: 'clear' }); + }); } export function searchArchive(query = '') { diff --git a/brainsnn-r3f-app/src/utils/searchWorker.js b/brainsnn-r3f-app/src/utils/searchWorker.js new file mode 100644 index 0000000..e452394 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/searchWorker.js @@ -0,0 +1,77 @@ +/** + * searchWorker — main-thread API for the CodeBrain worker. + * + * Spawns a dedicated worker on first use (lazy), keeps it alive for + * subsequent calls. The CodeBrain panel typically does + * analyze → many searches → re-analyze; reusing the worker avoids + * the worker-spawn cost on each query. + * + * Falls back to inline (sync) when Workers are unavailable. + */ + +import { createPool } from './workerPool'; +import { parseFiles as parseFilesInline } from './codeParser'; +import { + buildBM25Index as buildBM25IndexInline, + hybridSearch as hybridSearchInline +} from './bm25'; +import { detectCommunities as detectCommunitiesInline } from './communities'; + +let _pool = null; + +function ensurePool() { + if (_pool) return _pool; + if (typeof window === 'undefined') return null; + try { + _pool = createPool( + () => new Worker(new URL('../workers/search.worker.js', import.meta.url), { type: 'module' }), + { + size: 1, // serial code analysis fits one slot + fallback: (type, payload) => { + if (type === 'parseFiles') return parseFilesInline(payload?.files || []); + if (type === 'analyzeCode') { + const graph = parseFilesInline(payload?.files || []); + const communities = detectCommunitiesInline({ nodes: graph.nodes, edges: graph.edges }); + const index = buildBM25IndexInline(graph.docs); + return { ...graph, communities, index }; + } + if (type === 'hybridSearch') { + return hybridSearchInline(payload?.query || '', payload?.index, { topK: payload?.topK || 10 }); + } + return null; + } + } + ); + return _pool; + } catch { return null; } +} + +export async function analyzeCodeAsync(files) { + const pool = ensurePool(); + if (!pool || pool.degraded) { + const graph = parseFilesInline(files || []); + const communities = detectCommunitiesInline({ nodes: graph.nodes, edges: graph.edges }); + const index = buildBM25IndexInline(graph.docs); + return { ...graph, communities, index }; + } + try { + return await pool.call('analyzeCode', { files }); + } catch { + const graph = parseFilesInline(files || []); + const communities = detectCommunitiesInline({ nodes: graph.nodes, edges: graph.edges }); + const index = buildBM25IndexInline(graph.docs); + return { ...graph, communities, index }; + } +} + +export async function hybridSearchAsync(query, index, { topK = 10 } = {}) { + const pool = ensurePool(); + if (!pool || pool.degraded) { + return hybridSearchInline(query || '', index, { topK }); + } + try { + return await pool.call('hybridSearch', { query, index, topK }); + } catch { + return hybridSearchInline(query || '', index, { topK }); + } +} diff --git a/brainsnn-r3f-app/src/utils/snapshots.js b/brainsnn-r3f-app/src/utils/snapshots.js index 94a40a2..ae22fa9 100644 --- a/brainsnn-r3f-app/src/utils/snapshots.js +++ b/brainsnn-r3f-app/src/utils/snapshots.js @@ -4,9 +4,19 @@ * Stores named snapshots of the full brain state in localStorage. * Supports export/import as JSON, side-by-side comparison, and * generating shareable report summaries. + * + * Cross-tab safety: mutations run under a Web Locks key + * ('snapshots:write') so two tabs saving simultaneously can't clobber + * each other's appends. Each mutation also broadcasts on the + * 'snapshot:changed' channel so a list view in another tab refreshes + * without manual reload. */ +import { withLock } from './atomicWrites'; +import { publish } from './multiTab'; + const STORAGE_KEY = 'brainsnn_snapshots'; +const LOCK_KEY = 'snapshots:write'; // ---------- storage ---------- @@ -31,7 +41,6 @@ export function listSnapshots() { } export function saveSnapshot(state, name = '') { - const snapshots = readStore(); const regions = { ...state.regions }; const weights = { ...state.weights }; const mean = Object.values(regions).reduce((a, v) => a + v, 0) / Object.keys(regions).length; @@ -57,10 +66,16 @@ export function saveSnapshot(state, name = '') { } }; - snapshots.unshift(snapshot); - // Keep max 50 snapshots - if (snapshots.length > 50) snapshots.length = 50; - writeStore(snapshots); + // Public API stays synchronous-looking for callers; the lock runs + // in the background. Web Locks queue requests so callers within the + // same tab/across tabs don't race on the localStorage list. + withLock(LOCK_KEY, () => { + const snapshots = readStore(); + snapshots.unshift(snapshot); + if (snapshots.length > 50) snapshots.length = 50; + writeStore(snapshots); + publish('snapshot:changed', { kind: 'save', id: snapshot.id }); + }); return snapshot; } @@ -70,12 +85,18 @@ export function loadSnapshot(id) { } export function deleteSnapshot(id) { - const snapshots = readStore().filter((s) => s.id !== id); - writeStore(snapshots); + withLock(LOCK_KEY, () => { + const snapshots = readStore().filter((s) => s.id !== id); + writeStore(snapshots); + publish('snapshot:changed', { kind: 'delete', id }); + }); } export function clearAllSnapshots() { - writeStore([]); + withLock(LOCK_KEY, () => { + writeStore([]); + publish('snapshot:changed', { kind: 'clear' }); + }); } // ---------- comparison ---------- diff --git a/brainsnn-r3f-app/src/utils/store.js b/brainsnn-r3f-app/src/utils/store.js new file mode 100644 index 0000000..c4d46ed --- /dev/null +++ b/brainsnn-r3f-app/src/utils/store.js @@ -0,0 +1,203 @@ +/** + * Unified persistence layer — IndexedDB-backed, localStorage fallback. + * + * Today BrainSNN stores 30+ collections in localStorage (`brainsnn_*_v1` + * keys), which hits the 5–10 MB browser quota once the user grows their + * archive / receipts / embeddings cache. This module is the destination + * for the migration plan in §10.3. + * + * API stays intentionally small so any caller (panel, util, worker via + * BroadcastChannel hop) can read/write without ceremony: + * + * import { openStore } from './store.js'; + * const archive = openStore('archive'); + * await archive.set('scan-42', { ts: Date.now(), pressure: 0.7 }); + * const row = await archive.get('scan-42'); + * const rows = await archive.values({ limit: 200 }); + * + * Collections are created lazily on first `openStore(name)`. New + * collections trigger a DB version bump on next open — this is handled + * transparently via a re-open path so callers never see a versionchange + * error. + * + * Fallback: if IndexedDB is unavailable (e.g. private mode + Safari), + * each collection transparently falls back to a namespaced localStorage + * key prefix (`brainsnn_store//`). + */ + +const DB_NAME = 'brainsnn'; +let dbPromise = null; +const collections = new Set(); + +function idbAvailable() { + try { + return typeof indexedDB !== 'undefined' && indexedDB !== null; + } catch { + return false; + } +} + +// Any DB handle we hand out must release itself when another tab triggers +// a version bump — otherwise the upgrade blocks forever and writes hang +// across tabs. +function attachVersionChange(db) { + db.onversionchange = () => { + try { db.close(); } catch { /* noop */ } + dbPromise = null; + }; + return db; +} + +function openDb(neededCollections) { + return new Promise((resolve, reject) => { + // Bump version when a collection is missing; otherwise keep current. + const probe = indexedDB.open(DB_NAME); + probe.onsuccess = () => { + const db = probe.result; + const haveAll = neededCollections.every((c) => db.objectStoreNames.contains(c)); + if (haveAll) { resolve(attachVersionChange(db)); return; } + const nextVersion = db.version + 1; + db.close(); + const upgrade = indexedDB.open(DB_NAME, nextVersion); + upgrade.onupgradeneeded = (e) => { + const target = e.target.result; + for (const c of neededCollections) { + if (!target.objectStoreNames.contains(c)) { + target.createObjectStore(c); + } + } + }; + upgrade.onsuccess = () => resolve(attachVersionChange(upgrade.result)); + upgrade.onerror = () => reject(upgrade.error); + // Another tab holding an older handle didn't release in time. Re-open + // path will retry once that tab closes its connection. + upgrade.onblocked = () => reject(new Error('IDB upgrade blocked')); + }; + probe.onerror = () => reject(probe.error); + }); +} + +async function getDb() { + if (!idbAvailable()) return null; + if (!dbPromise) { + dbPromise = openDb(Array.from(collections)); + } else if (collections.size > 0) { + const db = await dbPromise; + const missing = Array.from(collections).filter((c) => !db.objectStoreNames.contains(c)); + if (missing.length) { + dbPromise = openDb(Array.from(collections)); + } + } + return dbPromise; +} + +function lsKey(collection, id) { + return `brainsnn_store/${collection}/${id}`; +} + +function lsCollection(collection) { + const prefix = `brainsnn_store/${collection}/`; + return { + async get(id) { + try { return JSON.parse(localStorage.getItem(lsKey(collection, id)) || 'null'); } + catch { return null; } + }, + async set(id, value) { + try { localStorage.setItem(lsKey(collection, id), JSON.stringify(value)); } + catch { /* quota — silently drop */ } + }, + async delete(id) { + localStorage.removeItem(lsKey(collection, id)); + }, + async keys() { + const out = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) out.push(k.slice(prefix.length)); + } + return out; + }, + async values({ limit } = {}) { + const keys = await this.keys(); + const slice = typeof limit === 'number' ? keys.slice(0, limit) : keys; + return Promise.all(slice.map((k) => this.get(k))); + }, + async clear() { + const keys = await this.keys(); + for (const k of keys) localStorage.removeItem(lsKey(collection, k)); + } + }; +} + +export function openStore(collection) { + collections.add(collection); + if (!idbAvailable()) return lsCollection(collection); + + return { + async get(id) { + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(collection, 'readonly'); + const req = tx.objectStore(collection).get(id); + req.onsuccess = () => resolve(req.result ?? null); + req.onerror = () => reject(req.error); + }); + }, + async set(id, value) { + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(collection, 'readwrite'); + const req = tx.objectStore(collection).put(value, id); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + }, + async delete(id) { + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(collection, 'readwrite'); + const req = tx.objectStore(collection).delete(id); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + }, + async keys() { + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(collection, 'readonly'); + const req = tx.objectStore(collection).getAllKeys(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + }, + async values({ limit } = {}) { + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(collection, 'readonly'); + const req = tx.objectStore(collection).getAll(undefined, limit); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + }, + async clear() { + const db = await getDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(collection, 'readwrite'); + const req = tx.objectStore(collection).clear(); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } + }; +} + +/** + * Estimate total IDB+localStorage usage. Surfaces in the Privacy Budget + * panel (Layer 86) once it's wired to this store. + */ +export async function storageEstimate() { + if (typeof navigator !== 'undefined' && navigator.storage?.estimate) { + try { return await navigator.storage.estimate(); } catch { /* noop */ } + } + return { usage: 0, quota: 0 }; +} diff --git a/brainsnn-r3f-app/src/utils/styleFingerprint.js b/brainsnn-r3f-app/src/utils/styleFingerprint.js index 1fff8f2..86e6326 100644 --- a/brainsnn-r3f-app/src/utils/styleFingerprint.js +++ b/brainsnn-r3f-app/src/utils/styleFingerprint.js @@ -133,11 +133,11 @@ export function compareFingerprints(a, b) { } export function similarityVerdict(sim) { - if (sim >= 0.97) return { label: 'Very likely same author', color: '#5ee69a' }; - if (sim >= 0.90) return { label: 'Likely same author', color: '#77dbe4' }; - if (sim >= 0.78) return { label: 'Plausible overlap', color: '#fdab43' }; + if (sim >= 0.97) return { label: 'Very likely same author', color: 'var(--severity-ok)' }; + if (sim >= 0.90) return { label: 'Likely same author', color: 'var(--severity-info)' }; + if (sim >= 0.78) return { label: 'Plausible overlap', color: 'var(--severity-mid)' }; if (sim >= 0.60) return { label: 'Weak overlap', color: '#e57b40' }; - return { label: 'Distinct styles', color: '#dd6974' }; + return { label: 'Distinct styles', color: 'var(--danger)' }; } // Labels for the 12 features, used by the panel's bar display diff --git a/brainsnn-r3f-app/src/utils/swUpdate.js b/brainsnn-r3f-app/src/utils/swUpdate.js new file mode 100644 index 0000000..ab0668b --- /dev/null +++ b/brainsnn-r3f-app/src/utils/swUpdate.js @@ -0,0 +1,133 @@ +/** + * swUpdate — service-worker lifecycle observer. + * + * Surfaces three states to the UI: + * - 'pending' a new SW is downloading + * - 'waiting' a new SW is installed but the user is still on the + * old shell; reload to activate + * - 'active' SW is up-to-date + * + * The Beast PR #11 service worker calls skipWaiting() during install, + * which means a new SW takes over IMMEDIATELY on first idle tab close. + * That's fine for fresh visits, but a tab the user has open during a + * deploy will hold the OLD shell loaded and may 404 against the NEW + * chunks. This observer detects the window and lets the UI offer a + * "new version available — reload" action so the user can rehydrate + * intentionally instead of mid-task. + */ + +const _subs = new Set(); +let _state = { status: 'unknown' }; +let _registration = null; +let _started = false; + +function broadcast(patch) { + _state = { ..._state, ...patch }; + for (const cb of _subs) { + try { cb(_state); } catch { /* noop */ } + } +} + +function track(reg) { + _registration = reg; + // True once we've actually observed an update transition. Guards + // against `controllerchange` firing on first-time install (no prior + // controller → a fresh user would otherwise see "new version + // ready" before they've done anything). + let sawUpdate = false; + + // Already waiting on page load — happens when a SW updated while + // the tab was closed. + if (reg.waiting && navigator.serviceWorker.controller) { + broadcast({ status: 'waiting' }); + } else if (reg.active) { + broadcast({ status: 'active' }); + } + + // Active SW slot changed (most commonly the new worker took over). + reg.addEventListener('updatefound', () => { + const installing = reg.installing; + if (!installing) return; + // Only flag this as an actual update if there was already a + // controller before — otherwise this is the first-install path + // and the user doesn't need a reload prompt. + if (navigator.serviceWorker.controller) sawUpdate = true; + broadcast({ status: 'pending' }); + installing.addEventListener('statechange', () => { + if (installing.state === 'installed') { + // If there's already an active controller, this new worker is + // waiting. If not, it's a first-install and goes straight to + // active — no need to nudge the user. + if (navigator.serviceWorker.controller) { + sawUpdate = true; + broadcast({ status: 'waiting' }); + } else { + broadcast({ status: 'active' }); + } + } else if (installing.state === 'activated') { + broadcast({ status: 'active' }); + } else if (installing.state === 'redundant') { + broadcast({ status: 'active' }); + } + }); + }); + + // Worker took over — reload anything stale at the next nav. + // controllerchange ALSO fires on the very first SW install (no + // previous controller → a fresh controller). Gate the 'waiting' + // emit so we only surface the reload banner when an actual update + // has been observed (sawUpdate) OR a worker is currently waiting. + if (navigator.serviceWorker) { + navigator.serviceWorker.addEventListener('controllerchange', () => { + const reallyWaiting = sawUpdate || !!_registration?.waiting; + if (reallyWaiting) { + broadcast({ status: 'waiting' }); + } + }); + } +} + +export function startSwUpdateWatch() { + if (_started) return; + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + _started = true; + navigator.serviceWorker.ready + .then((reg) => track(reg)) + .catch(() => { /* noop */ }); + + // Periodically check for updates. Browsers do this on focus + page + // load anyway, but a 30-minute heartbeat catches long-running tabs. + setInterval(() => { + if (_registration && document.visibilityState === 'visible') { + _registration.update().catch(() => { /* noop */ }); + } + }, 30 * 60 * 1000); +} + +export function subscribeSwUpdate(cb) { + _subs.add(cb); + cb(_state); + return () => _subs.delete(cb); +} + +export function getSwUpdateState() { + return { ..._state }; +} + +/** + * Tell the waiting worker to take over immediately, then reload so + * we boot against the new chunks. + */ +export async function activateNewSw() { + if (!_registration?.waiting) { + // No waiting worker — just reload (might be a stuck state). + window.location.reload(); + return; + } + // sw.js listens for { type: 'skipWaiting' } and calls + // self.skipWaiting() in response. + _registration.waiting.postMessage({ type: 'skipWaiting' }); + // Wait one tick for controllerchange → reload. If skipWaiting + // doesn't fire (browser bug), fall back after 500 ms. + setTimeout(() => window.location.reload(), 500); +} diff --git a/brainsnn-r3f-app/src/utils/textAdventure.js b/brainsnn-r3f-app/src/utils/textAdventure.js index 95d09c7..fc15e0a 100644 --- a/brainsnn-r3f-app/src/utils/textAdventure.js +++ b/brainsnn-r3f-app/src/utils/textAdventure.js @@ -176,10 +176,10 @@ export function currentNode(run) { export function runSummary(run) { const node = currentNode(run); const terminal = !!node?.terminal; - const verdict = !terminal ? { label: 'In progress', color: '#77dbe4' } - : run.score >= 3 ? { label: 'Resilient', color: '#5ee69a' } - : run.score >= 1 ? { label: 'Sharp', color: '#77dbe4' } - : run.score >= -1 ? { label: 'Wobbled', color: '#fdab43' } - : { label: 'Hooked', color: '#dd6974' }; + const verdict = !terminal ? { label: 'In progress', color: 'var(--severity-info)' } + : run.score >= 3 ? { label: 'Resilient', color: 'var(--severity-ok)' } + : run.score >= 1 ? { label: 'Sharp', color: 'var(--severity-info)' } + : run.score >= -1 ? { label: 'Wobbled', color: 'var(--severity-mid)' } + : { label: 'Hooked', color: 'var(--danger)' }; return { terminal, verdict }; } diff --git a/brainsnn-r3f-app/src/utils/theme.js b/brainsnn-r3f-app/src/utils/theme.js index 73feff1..9a76220 100644 --- a/brainsnn-r3f-app/src/utils/theme.js +++ b/brainsnn-r3f-app/src/utils/theme.js @@ -8,9 +8,13 @@ * - reduceMotion: boolean * - fontScale: 0.9 | 1 | 1.15 | 1.3 * - * Persisted to brainsnn_theme_v1. Auto-applies on load. + * Persisted to brainsnn_theme_v1. Auto-applies on load. Theme changes + * broadcast on the multiTab channel so two tabs stay visually in sync + * when the user toggles in one. */ +import { publish, subscribe } from './multiTab'; + const STORAGE_KEY = 'brainsnn_theme_v1'; export const DEFAULT_THEME = { @@ -31,6 +35,7 @@ export function getTheme() { export function setTheme(next) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* quota */ } applyTheme(next); + publish('theme:changed', next); } function effectiveTheme(settings) { @@ -74,4 +79,12 @@ export function registerTheme() { const mq = window.matchMedia('(prefers-color-scheme: light)'); if (mq.addEventListener) mq.addEventListener('change', () => applyTheme(getTheme())); } + // Apply theme changes broadcast from other tabs — but only the + // visual side. Don't re-persist (the originating tab already did + // that) and don't re-broadcast (would echo forever). + subscribe('theme:changed', (next) => { + if (!next) return; + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* quota */ } + applyTheme(next); + }); } diff --git a/brainsnn-r3f-app/src/utils/threeTokens.js b/brainsnn-r3f-app/src/utils/threeTokens.js new file mode 100644 index 0000000..6cf66b4 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/threeTokens.js @@ -0,0 +1,74 @@ +/** + * threeTokens — bridge CSS custom properties into Three.js color space. + * + * Three.js parses CSS color strings (`'#c96442'`) but NOT CSS variables + * (`'var(--accent)'`) — its color parser doesn't traverse the cascade. + * This helper reads the resolved values from once, caches them, + * and exposes a React hook that re-reads on theme switches so the brain + * scene reskins when the user toggles light/dark or high-contrast. + */ + +import { useEffect, useState } from 'react'; + +const FALLBACKS = { + accent: '#c96442', + accentBright: '#e08868', + danger: '#c46666', + ok: '#7a9c5f', + warn: '#c9a14b', + bg3d: '#0f0e0c', + signal: '#e8b8a8', + floorTint: '#1f1c19' +}; + +let _cache = null; + +function readTokens() { + if (typeof window === 'undefined' || !document?.documentElement) return FALLBACKS; + const style = getComputedStyle(document.documentElement); + const read = (name, fb) => { + const v = style.getPropertyValue(name).trim(); + return v || fb; + }; + return { + accent: read('--accent', FALLBACKS.accent), + accentBright: read('--accent-bright', FALLBACKS.accentBright), + danger: read('--danger', FALLBACKS.danger), + ok: read('--ok', FALLBACKS.ok), + warn: read('--warn', FALLBACKS.warn), + bg3d: read('--bg-3d', FALLBACKS.bg3d), + signal: read('--accent-bright', FALLBACKS.signal), + floorTint: read('--surface-3', FALLBACKS.floorTint) + }; +} + +export function getThreeTokens() { + if (!_cache) _cache = readTokens(); + return _cache; +} + +export function invalidateThreeTokens() { + _cache = null; +} + +/** + * React hook — returns current tokens, re-reads when the user toggles + * theme or high-contrast (theme.js writes data-theme / data-high-contrast + * on ; we observe both attributes and invalidate the cache). + */ +export function useThreeTokens() { + const [tokens, setTokens] = useState(getThreeTokens); + useEffect(() => { + if (typeof window === 'undefined') return; + const observer = new MutationObserver(() => { + invalidateThreeTokens(); + setTokens(getThreeTokens()); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme', 'data-high-contrast'] + }); + return () => observer.disconnect(); + }, []); + return tokens; +} diff --git a/brainsnn-r3f-app/src/utils/workerPool.js b/brainsnn-r3f-app/src/utils/workerPool.js new file mode 100644 index 0000000..d7da720 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/workerPool.js @@ -0,0 +1,127 @@ +/** + * Worker pool — Comlink-lite postMessage proxy. + * + * Spawns N module workers (default: hardwareConcurrency - 1, capped 4), + * round-robins requests, and resolves a Promise per request via reply ID. + * + * Used by the firewall / evolve / search / embeddings tracks (§10 of the + * shell redesign plan) to keep heavy work off the main thread so the 3D + * brain renders at full framerate during long scans. + * + * Usage: + * const pool = createPool(() => new Worker( + * new URL('../workers/firewall.worker.js', import.meta.url), + * { type: 'module' } + * ), { size: 2 }); + * const result = await pool.call('score', { text: '…' }); + * + * Graceful fallback: if Worker is unavailable (SSR, ancient browsers, test + * env) `call()` invokes the optional `fallback` fn synchronously. + */ + +const DEFAULT_SIZE = Math.max( + 1, + Math.min(4, (typeof navigator !== 'undefined' && navigator.hardwareConcurrency - 1) || 2) +); + +let nextRequestId = 0; + +function workerAvailable() { + return typeof Worker !== 'undefined' && typeof URL !== 'undefined'; +} + +export function createPool(workerFactory, { size = DEFAULT_SIZE, fallback = null } = {}) { + if (!workerAvailable()) { + return { + call: async (type, payload) => { + if (!fallback) throw new Error('workerPool: no Worker and no fallback'); + return fallback(type, payload); + }, + terminate: () => {}, + size: 0, + degraded: true + }; + } + + const workers = []; + const pending = new Map(); + let cursor = 0; + + function spawn() { + const w = workerFactory(); + w.onmessage = (e) => { + const { id, ok, result, error } = e.data || {}; + const slot = pending.get(id); + if (!slot) return; + pending.delete(id); + if (ok) slot.resolve(result); + else slot.reject(new Error(error || 'worker error')); + }; + w.onerror = (err) => { + // surface to console; per-request rejections handled via onmessage error path + // eslint-disable-next-line no-console + console.warn('[workerPool] worker error', err.message || err); + }; + return w; + } + + for (let i = 0; i < size; i++) workers.push(spawn()); + + return { + size: workers.length, + degraded: false, + call(type, payload, transferList) { + return new Promise((resolve, reject) => { + const id = ++nextRequestId; + pending.set(id, { resolve, reject }); + const w = workers[cursor++ % workers.length]; + try { + w.postMessage({ id, type, payload }, transferList || []); + } catch (err) { + pending.delete(id); + if (fallback) { + try { resolve(fallback(type, payload)); } catch (fe) { reject(fe); } + } else { + reject(err); + } + } + }); + }, + terminate() { + for (const w of workers) { + try { w.terminate(); } catch { /* noop */ } + } + workers.length = 0; + pending.clear(); + } + }; +} + +/** + * Standard worker-side request handler. Wires a single `handlers` object + * (type -> async fn) to self.onmessage and posts back the canonical reply + * envelope expected by the pool above. + * + * // inside a worker: + * import { handleRequests } from '../utils/workerPool.js'; + * handleRequests({ + * score: async ({ text }) => scoreContent(text), + * }); + */ +export function handleRequests(handlers) { + // self is the DedicatedWorkerGlobalScope inside a worker + self.onmessage = async (e) => { + const { id, type, payload } = e.data || {}; + const fn = handlers[type]; + if (!fn) { + self.postMessage({ id, ok: false, error: `no handler: ${type}` }); + return; + } + try { + const result = await fn(payload); + self.postMessage({ id, ok: true, result }); + } catch (err) { + self.postMessage({ id, ok: false, error: err?.message || String(err) }); + } + }; +} diff --git a/brainsnn-r3f-app/src/workers/attackEvolve.worker.js b/brainsnn-r3f-app/src/workers/attackEvolve.worker.js new file mode 100644 index 0000000..03aa589 --- /dev/null +++ b/brainsnn-r3f-app/src/workers/attackEvolve.worker.js @@ -0,0 +1,48 @@ +/** + * attackEvolve.worker.js — Attack Evolve loop in a dedicated worker. + * + * Sister to evolve.worker.js (Brain Evolve). Same streaming protocol: + * main → worker: { type: 'start', payload: } + * main → worker: { type: 'stop' } + * worker → main: { type: 'round', payload: { round, total, child, pool, generation } } + * worker → main: { type: 'done', payload: { pool, best, sampler } } + * worker → main: { type: 'error', payload: { message } } + * + * Note: runAttackEvolution requires an initialPool seeded with the + * current red-team corpus; the wrapper computes that on the main + * thread before dispatching (corpus access in a worker would need + * cross-thread coherence on Layer 25's mutable corpus state). + */ + +import { runAttackEvolution } from '../utils/evolve/attackLoop.js'; + +let stopped = false; + +self.onmessage = async (e) => { + const { type, payload } = e.data || {}; + + if (type === 'stop') { + stopped = true; + return; + } + + if (type !== 'start') return; + + stopped = false; + try { + const opts = { + ...payload, + shouldStop: () => stopped, + onRound: ({ round, total, child, pool, generation }) => { + self.postMessage({ + type: 'round', + payload: { round, total, child, pool, generation } + }); + } + }; + const { pool, best, sampler } = await runAttackEvolution(opts); + self.postMessage({ type: 'done', payload: { pool, best, sampler } }); + } catch (err) { + self.postMessage({ type: 'error', payload: { message: err?.message || String(err) } }); + } +}; diff --git a/brainsnn-r3f-app/src/workers/embeddings.worker.js b/brainsnn-r3f-app/src/workers/embeddings.worker.js new file mode 100644 index 0000000..ed0f1b3 --- /dev/null +++ b/brainsnn-r3f-app/src/workers/embeddings.worker.js @@ -0,0 +1,73 @@ +/** + * embeddings.worker.js — MiniLM-L6 in a dedicated worker. + * + * Loads transformers.js from CDN once on spawn, instantiates the + * `Xenova/all-MiniLM-L6-v2` pipeline, and exposes embed / embedBatch + * over the standard workerPool envelope. The main thread never blocks + * on model load. + * + * Cache is intentionally NOT here — IDB lives on the main thread (the + * worker has its own IDB but cross-thread coherence is messy). The + * facade in src/utils/embeddings.js checks the cache before calling + * the worker. + */ + +import { handleRequests } from '../utils/workerPool.js'; + +const CDN_URL = 'https://esm.run/@xenova/transformers@2.17.2'; +const MODEL_ID = 'Xenova/all-MiniLM-L6-v2'; + +let pipelinePromise = null; +let pipelineInstance = null; +let status = { state: 'idle', error: null, modelId: MODEL_ID }; + +async function ensurePipeline() { + if (pipelineInstance) return pipelineInstance; + if (pipelinePromise) return pipelinePromise; + + status = { ...status, state: 'loading', error: null }; + pipelinePromise = (async () => { + try { + const mod = await import(/* @vite-ignore */ CDN_URL); + status = { ...status, state: 'model-loading' }; + pipelineInstance = await mod.pipeline('feature-extraction', MODEL_ID, { quantized: true }); + status = { ...status, state: 'ready', error: null }; + return pipelineInstance; + } catch (err) { + status = { ...status, state: 'error', error: err?.message || String(err) }; + pipelinePromise = null; + throw err; + } + })(); + return pipelinePromise; +} + +handleRequests({ + // Kick off model load without blocking. Main thread polls getStatus. + warmup: async () => { + ensurePipeline().catch(() => { /* status reflects the error */ }); + return { ok: true, status }; + }, + + getStatus: async () => status, + + embed: async ({ text }) => { + if (!text || typeof text !== 'string') throw new Error('embed: text required'); + const pipe = await ensurePipeline(); + const output = await pipe(text, { pooling: 'mean', normalize: true }); + // Return as a plain Array for now; transferring Float32Array via + // structured clone would also work but adds boilerplate around + // detached buffers when the consumer also caches the vector. + return { vec: Array.from(output.data) }; + }, + + embedBatch: async ({ texts }) => { + const pipe = await ensurePipeline(); + const out = []; + for (const t of texts || []) { + const o = await pipe(t, { pooling: 'mean', normalize: true }); + out.push(Array.from(o.data)); + } + return { vecs: out }; + } +}); diff --git a/brainsnn-r3f-app/src/workers/evolve.worker.js b/brainsnn-r3f-app/src/workers/evolve.worker.js new file mode 100644 index 0000000..105ef49 --- /dev/null +++ b/brainsnn-r3f-app/src/workers/evolve.worker.js @@ -0,0 +1,52 @@ +/** + * evolve.worker.js — Brain Evolve loop in a dedicated worker. + * + * Hosts the Layer 31 evolution machinery (UCB1 / Island / MAP-Elites + * samplers + n-gram mining + mutation operators + red-team scoring) + * off the main thread. Each round's red-team evaluation is the + * expensive step — running it in a worker keeps the 3D brain at + * full framerate during a 16-round evolution. + * + * Streaming protocol (one-shot worker per evolution run): + * main → worker: { type: 'start', payload: } + * main → worker: { type: 'stop' } // cooperative cancel + * worker → main: { type: 'round', payload: { round, total, child, pool, generation } } + * worker → main: { type: 'done', payload: { pool, best, sampler } } + * worker → main: { type: 'error', payload: { message } } + */ + +import { runEvolution, seedNode } from '../utils/evolve/loop.js'; + +let stopped = false; + +self.onmessage = async (e) => { + const { type, payload } = e.data || {}; + + if (type === 'stop') { + stopped = true; + return; + } + + if (type !== 'start') return; + + stopped = false; + try { + const initialPool = payload?.initialPool || [seedNode()]; + const opts = { + ...payload, + initialPool, + shouldStop: () => stopped, + onRound: ({ round, total, child, pool, generation }) => { + // Strip non-serializable fields if any sneaked in (defensive). + self.postMessage({ + type: 'round', + payload: { round, total, child, pool, generation } + }); + } + }; + const { pool, best, sampler } = await runEvolution(opts); + self.postMessage({ type: 'done', payload: { pool, best, sampler } }); + } catch (err) { + self.postMessage({ type: 'error', payload: { message: err?.message || String(err) } }); + } +}; diff --git a/brainsnn-r3f-app/src/workers/firewall.worker.js b/brainsnn-r3f-app/src/workers/firewall.worker.js new file mode 100644 index 0000000..7d56e01 --- /dev/null +++ b/brainsnn-r3f-app/src/workers/firewall.worker.js @@ -0,0 +1,68 @@ +/** + * Firewall worker — runs the Cognitive Firewall + propaganda template + * detection off the main thread. + * + * The firewall's regex sweep is O(patterns × text length); on a long + * article (50k+ chars) it freezes the 3D viewer for ~800ms. Pushing it + * here lets BrainScene render uninterrupted during the scan. + * + * Active ruleset is stateful per-worker (setActiveRules/getActiveRules in + * cognitiveFirewall.js use module-level vars). For now the worker uses + * DEFAULT_RULES; rule promotion (Brain Evolve, custom rules) will be + * propagated via a 'setRules' message in a follow-up. + */ + +import { handleRequests } from '../utils/workerPool.js'; +import { + scoreContent, + deserializeRules, + setActiveRules, + getActiveRules +} from '../utils/cognitiveFirewall.js'; +import { runRedTeam } from '../utils/redTeam.js'; +import { trainFromRedTeam } from '../utils/adversarialTraining.js'; + +handleRequests({ + score: async ({ text }) => scoreContent(text || ''), + + // Full pipeline equivalent to scoreContent on the main thread, using + // a caller-supplied ruleset. Workers process one message at a time, so + // temporarily swapping active rules around the call is safe (no race). + // Restore the previous ruleset on exit so callers don't have to track + // worker state. + scoreWithRules: async ({ text, rules }) => { + const rs = rules ? deserializeRules(rules) : null; + const prev = getActiveRules(); + if (rs) setActiveRules(rs); + try { + return scoreContent(text || ''); + } finally { + setActiveRules(prev); + } + }, + + setRules: async ({ rules }) => { + setActiveRules(rules ? deserializeRules(rules) : null); + return { ok: true }; + }, + + // Run the full 65-sample red-team corpus + compute per-threshold + // F1 / detection / FPR. Heavy: ~150 ms per ruleset on default + // hardware. Worker variant uses ITS active rules (set via setRules + // beforehand or supplied inline), not the main thread's. + runRedTeam: async ({ thresholds, rules } = {}) => { + const prev = rules ? getActiveRules() : null; + if (rules) setActiveRules(deserializeRules(rules)); + try { + // Strip onProgress — postMessage can't carry functions, and + // panel-side progress is overkill at <200ms total. + return runRedTeam({ thresholds }); + } finally { + if (rules) setActiveRules(prev); + } + }, + + // n-gram lift mining over a runRedTeam report. Pure compute on + // serializable data; no rule state needed. + trainFromRedTeam: async ({ report, opts }) => trainFromRedTeam(report, opts || {}) +}); diff --git a/brainsnn-r3f-app/src/workers/search.worker.js b/brainsnn-r3f-app/src/workers/search.worker.js new file mode 100644 index 0000000..6cd7ea3 --- /dev/null +++ b/brainsnn-r3f-app/src/workers/search.worker.js @@ -0,0 +1,37 @@ +/** + * search.worker.js — CodeBrain workloads off the main thread. + * + * The Layer 20 panel parses source files, builds a code graph, + * detects Louvain communities, and indexes for BM25 + trigram + * hybrid search. On large pastes this freezes the UI for a second + * or two. All three steps are pure compute over JSON-safe data — + * perfect worker fodder. + * + * Handlers: + * parseFiles ({ files }) → graph + * analyzeCode ({ files }) → { graph, communities, index } + * hybridSearch ({ query, index, topK }) → results + * + * Communities + BM25 are folded into `analyzeCode` because the + * CodeBrain panel always runs them together; saves a postMessage + * round-trip. + */ + +import { handleRequests } from '../utils/workerPool.js'; +import { parseFiles } from '../utils/codeParser.js'; +import { buildBM25Index, hybridSearch } from '../utils/bm25.js'; +import { detectCommunities } from '../utils/communities.js'; + +handleRequests({ + parseFiles: async ({ files }) => parseFiles(files || []), + + analyzeCode: async ({ files }) => { + const graph = parseFiles(files || []); + const communities = detectCommunities({ nodes: graph.nodes, edges: graph.edges }); + const index = buildBM25Index(graph.docs); + return { ...graph, communities, index }; + }, + + hybridSearch: async ({ query, index, topK }) => + hybridSearch(query || '', index, { topK: topK || 10 }) +}); diff --git a/brainsnn-r3f-app/vite.config.js b/brainsnn-r3f-app/vite.config.js index b12bcc8..8ab797f 100644 --- a/brainsnn-r3f-app/vite.config.js +++ b/brainsnn-r3f-app/vite.config.js @@ -1,5 +1,17 @@ import { defineConfig } from 'vite'; +/** + * Per-workspace bundle splits (Beast PR #10). + * + * Each workspace's drawer panels lazy-load inside the workspace + * component via React.lazy; this manualChunks map ALSO groups them + * into named chunks so the workspace's initial fetch is one HTTP + * round-trip instead of N panels at N round-trips. + * + * The legacy App.jsx scroll is its own chunk (`legacy-app`), loaded + * only when ?shell=old is set — so the default load never pulls + * the linear-scroll markup. + */ export default defineConfig({ base: '/', build: { @@ -7,9 +19,28 @@ export default defineConfig({ chunkSizeWarningLimit: 1400, rollupOptions: { output: { - manualChunks: { - three: ['three', '@react-three/fiber', '@react-three/drei'], - postprocessing: ['@react-three/postprocessing', 'postprocessing'], + manualChunks(id) { + // External vendor chunks first. + if (id.includes('node_modules')) { + if (id.match(/\/(three|@react-three)\b/)) return 'three'; + if (id.includes('@react-three/postprocessing') || id.includes('/postprocessing/')) return 'postprocessing'; + if (id.includes('react') && !id.includes('react-three')) return 'react'; + return undefined; + } + + // Workspace chunks. Each workspace JSX + the panels it + // lazy-imports land in the same chunk for one-round-trip load. + const m = id.match(/\/src\/shell\/workspaces\/(\w+)Workspace\.jsx$/); + if (m) return `ws-${m[1].toLowerCase()}`; + + // Worker source code (Vite handles them as separate entries + // automatically, but the IDB store + token helpers shared + // with workers benefit from a stable chunk). + if (id.includes('/src/utils/store.js') || id.includes('/src/utils/workerPool.js')) { + return 'shared-utils'; + } + + return undefined; } } }